diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6991b07fd..12bf2a656 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -167,6 +167,9 @@ jobs: echo "registryctl_tutorial=${registryctl_tutorial}" } >> "${GITHUB_OUTPUT}" + - name: Check Debian 13 image contract + run: python3 release/scripts/check-debian13-images.py + secrets: name: Secret scan runs-on: ubuntu-24.04 @@ -558,10 +561,42 @@ jobs: - name: Test upgrade exercise validator run: python3 -m unittest release/scripts/test_validate_upgrade_exercise.py - - name: Validate upgrade exercise template + - name: Test upgrade exercise asset preparation + run: python3 -m unittest release/scripts/test_prepare_upgrade_exercise_assets.py + + - name: Prepare committed upgrade candidate assets + id: upgrade-assets + env: + GH_TOKEN: ${{ github.token }} run: >- - python3 release/scripts/validate-upgrade-exercise.py --template - release/exercises/upgrade-exercise-v1.template.json + python3 release/scripts/prepare-upgrade-exercise-assets.py + --discover release/exercises + --asset-root target/upgrade-exercise-assets + --github-output "${GITHUB_OUTPUT}" + + - name: Install cosign for committed upgrade evidence + if: steps.upgrade-assets.outputs.has_candidates == 'true' + uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3 + + - name: Install SLSA verifier for committed upgrade evidence + if: steps.upgrade-assets.outputs.has_candidates == 'true' + env: + SLSA_VERIFIER_SHA256: 946dbec729094195e88ef78e1734324a27869f03e2c6bd2f61cbc06bd5350339 + SLSA_VERIFIER_VERSION: v2.7.1 + shell: bash + run: | + set -euo pipefail + tools_dir="${RUNNER_TEMP}/upgrade-evidence-tools" + mkdir -p "${tools_dir}" + curl --proto '=https' --tlsv1.2 --fail --location --retry 3 \ + --output "${tools_dir}/slsa-verifier" \ + "https://github.com/slsa-framework/slsa-verifier/releases/download/${SLSA_VERIFIER_VERSION}/slsa-verifier-linux-amd64" + echo "${SLSA_VERIFIER_SHA256} ${tools_dir}/slsa-verifier" | sha256sum --check + chmod 0755 "${tools_dir}/slsa-verifier" + echo "${tools_dir}" >> "${GITHUB_PATH}" + + - name: Validate committed upgrade exercise records + run: python3 release/scripts/validate-upgrade-exercise.py --discover release/exercises --candidate-asset-root target/upgrade-exercise-assets - name: Stable surface compatibility env: @@ -644,9 +679,7 @@ jobs: path: | target/registryctl-tutorial-cargo-home target/registryctl-tutorial-linux-amd64 - key: registryctl-tutorial-${{ runner.os }}-rust-1.95.0-${{ hashFiles('Cargo.lock') }} - restore-keys: | - registryctl-tutorial-${{ runner.os }}-rust-1.95.0- + key: registryctl-tutorial-${{ runner.os }}-${{ hashFiles('docs/site/scripts/check-registryctl-tutorials.sh', 'Cargo.lock') }} - name: Execute registryctl tutorials from source working-directory: docs/site diff --git a/crates/registry-notary-core/src/config/schema.rs b/crates/registry-notary-core/src/config/schema.rs index 2fa478a0b..16fa870f2 100644 --- a/crates/registry-notary-core/src/config/schema.rs +++ b/crates/registry-notary-core/src/config/schema.rs @@ -18,6 +18,36 @@ use super::{SigningKeyProviderConfig, SigningKeyStatus, StandaloneRegistryNotary pub const CONFIG_SCHEMA_ID: &str = "https://id.registrystack.org/schemas/registry-notary/registry-notary.config.schema.json"; +/// Schema-only deployment-waiver reference contract shared with posture. +pub(crate) struct DeploymentWaiverReferenceSchema; + +impl JsonSchema for DeploymentWaiverReferenceSchema { + fn schema_name() -> Cow<'static, str> { + "DeploymentWaiverReference".into() + } + + fn json_schema(_: &mut SchemaGenerator) -> Schema { + registry_platform_ops::deployment_waiver_reference_schema_fragment() + .try_into() + .expect("the shared waiver-reference fragment is a valid JSON Schema") + } +} + +/// Schema-only structural deployment-waiver summary contract shared with posture. +pub(crate) struct DeploymentWaiverSummarySchema; + +impl JsonSchema for DeploymentWaiverSummarySchema { + fn schema_name() -> Cow<'static, str> { + "DeploymentWaiverSummary".into() + } + + fn json_schema(_: &mut SchemaGenerator) -> Schema { + registry_platform_ops::deployment_waiver_summary_schema_fragment() + .try_into() + .expect("the shared waiver-summary fragment is a valid JSON Schema") + } +} + /// Schema-only contract for values parsed by `humantime_serde`. pub(crate) struct HumantimeDurationSchema; diff --git a/crates/registry-notary-core/src/config/tests/root.rs b/crates/registry-notary-core/src/config/tests/root.rs index a61c11650..1c0939604 100644 --- a/crates/registry-notary-core/src/config/tests/root.rs +++ b/crates/registry-notary-core/src/config/tests/root.rs @@ -389,7 +389,8 @@ pub(super) fn deployment_block_round_trips_through_yaml() { profile: production waivers: - finding: notary.openapi.public - reason: synthetic partner integration waiver + reference: OPS-2026-0042 + summary: Synthetic partner integration waiver expires: 2099-09-30 "#, ) @@ -403,6 +404,58 @@ waivers: .expect("production config with waivable waiver validates"); } +#[test] +pub(super) fn deployment_waiver_rejects_legacy_reason_as_unknown() { + let result: Result = serde_norway::from_str( + r#" +profile: hosted_lab +waivers: + - finding: notary.openapi.public + reference: OPS-2026-0042 + reason: legacy waiver text + expires: 2099-09-30 +"#, + ); + assert!( + result.is_err(), + "the removed reason field must fail strict deserialization" + ); +} + +#[test] +pub(super) fn deployment_waiver_rejects_missing_reference() { + let result: Result = serde_norway::from_str( + r#" +profile: hosted_lab +waivers: + - finding: notary.openapi.public + expires: 2099-09-30 +"#, + ); + assert!( + result.is_err(), + "a waiver without the required reference must fail deserialization" + ); +} + +#[test] +pub(super) fn deployment_waiver_rejects_explicit_null_summary() { + let result: Result = serde_norway::from_str( + r#" +profile: hosted_lab +waivers: + - finding: notary.openapi.public + reference: OPS-2026-0042 + summary: null + expires: 2099-09-30 +"#, + ); + assert!( + result.is_err(), + "summary must be a string when present, not null: {result:?}" + ); +} + #[test] pub(super) fn deployment_evidence_block_round_trips_through_yaml() { let mut config = minimal_config(); diff --git a/crates/registry-notary-core/src/deployment.rs b/crates/registry-notary-core/src/deployment.rs index c1310642c..8b5e6ca33 100644 --- a/crates/registry-notary-core/src/deployment.rs +++ b/crates/registry-notary-core/src/deployment.rs @@ -162,23 +162,54 @@ impl DeploymentEvidenceConfig { /// One operator-configured waiver. /// -/// A waiver names exactly one finding id, a free-text reason, and a mandatory -/// expiry date (`YYYY-MM-DD`). Reasons must not contain secrets. +/// A waiver names exactly one finding id, a required operator reference, an +/// optional summary, and a mandatory expiry date (`YYYY-MM-DD`). The shared +/// operations contract validates metadata before it can reach posture or logs. #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] +#[serde(deny_unknown_fields, from = "DeploymentWaiverConfigDocument")] +#[schemars(!from)] pub struct DeploymentWaiverConfig { pub finding: String, - pub reason: String, + #[schemars(with = "crate::config::schema::DeploymentWaiverReferenceSchema")] + pub reference: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(with = "crate::config::schema::DeploymentWaiverSummarySchema")] + pub summary: Option, pub expires: String, } +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct DeploymentWaiverConfigDocument { + finding: String, + reference: String, + #[serde(default)] + summary: registry_platform_ops::OptionalDeploymentWaiverSummary, + expires: String, +} + +impl From for DeploymentWaiverConfig { + fn from(value: DeploymentWaiverConfigDocument) -> Self { + Self { + finding: value.finding, + reference: value.reference, + summary: value.summary.into(), + expires: value.expires, + } + } +} + /// Errors raised while validating the deployment block at config load. #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] pub enum DeploymentConfigError { #[error("deployment.waivers[{index}].finding must not be empty")] EmptyWaiverFinding { index: usize }, - #[error("deployment.waivers[{index}].reason must not be empty")] - EmptyWaiverReason { index: usize }, + #[error("deployment.waivers[{index}].{field} is invalid: {error}")] + InvalidWaiverMetadata { + index: usize, + field: &'static str, + error: registry_platform_ops::DeploymentWaiverMetadataError, + }, #[error("deployment.waivers[{index}].expires must be a YYYY-MM-DD date")] InvalidWaiverExpiry { index: usize }, #[error( @@ -194,19 +225,25 @@ pub enum DeploymentConfigError { impl DeploymentConfig { /// Validate the deployment block at config load. /// - /// This checks waiver shape (non-empty fields, parseable expiry) and the - /// hard rule that `startup_fail` and `readiness_fail` gates can never be - /// waived under the declared profile. An undeclared profile still validates - /// waiver shape here so typos are caught early; startup refusal is handled by - /// gate evaluation. + /// This checks waiver shape (shared metadata contract, parseable expiry) + /// and the hard rule that `startup_fail` and `readiness_fail` gates can + /// never be waived under the declared profile. An undeclared profile still + /// validates waiver shape here so typos are caught early; startup refusal + /// is handled by gate evaluation. pub fn validate(&self) -> Result<(), DeploymentConfigError> { for (index, waiver) in self.waivers.iter().enumerate() { if waiver.finding.trim().is_empty() { return Err(DeploymentConfigError::EmptyWaiverFinding { index }); } - if waiver.reason.trim().is_empty() { - return Err(DeploymentConfigError::EmptyWaiverReason { index }); - } + registry_platform_ops::validate_deployment_waiver_metadata( + &waiver.reference, + waiver.summary.as_deref(), + ) + .map_err(|error| DeploymentConfigError::InvalidWaiverMetadata { + index, + field: error.field(), + error, + })?; if parse_iso_date(&waiver.expires).is_none() { return Err(DeploymentConfigError::InvalidWaiverExpiry { index }); } @@ -461,7 +498,8 @@ pub struct EvaluatedFinding { #[derive(Debug, Clone, PartialEq, Eq)] pub struct EvaluatedWaiver { pub finding: String, - pub reason: String, + pub reference: String, + pub summary: Option, pub expires: String, } @@ -518,7 +556,8 @@ pub fn evaluate_gates( status: DeploymentFindingStatus::Active, waiver: Some(EvaluatedWaiver { finding: waiver.finding.clone(), - reason: waiver.reason.clone(), + reference: waiver.reference.clone(), + summary: waiver.summary.clone(), expires: waiver.expires.clone(), }), }); @@ -536,7 +575,8 @@ pub fn evaluate_gates( waived_findings.push(waiver); evaluation.active_waivers.push(EvaluatedWaiver { finding: waiver.finding.clone(), - reason: waiver.reason.clone(), + reference: waiver.reference.clone(), + summary: waiver.summary.clone(), expires: waiver.expires.clone(), }); } @@ -568,7 +608,8 @@ pub fn evaluate_gates( status: DeploymentFindingStatus::Waived, waiver: Some(EvaluatedWaiver { finding: waiver.finding.clone(), - reason: waiver.reason.clone(), + reference: waiver.reference.clone(), + summary: waiver.summary.clone(), expires: waiver.expires.clone(), }), }); @@ -627,7 +668,8 @@ mod tests { fn waiver(finding: &str, expires: &str) -> DeploymentWaiverConfig { DeploymentWaiverConfig { finding: finding.to_string(), - reason: "synthetic test waiver reason".to_string(), + reference: "OPS-TEST-DEPLOYMENT".to_string(), + summary: Some("Synthetic test waiver summary".to_string()), expires: expires.to_string(), } } @@ -834,6 +876,77 @@ mod tests { )); } + #[test] + fn validate_accepts_absent_summary_and_ordinary_metadata() { + let mut waiver_config = waiver(FINDING_OPENAPI_PUBLIC, "2099-01-01"); + waiver_config.reference = "OPS-2026:INC_42".to_string(); + waiver_config.summary = None; + let config = DeploymentConfig { + profile: Some(DeploymentProfile::HostedLab), + multi_instance: false, + waivers: vec![waiver_config], + evidence: DeploymentEvidenceConfig::default(), + }; + config + .validate() + .expect("ordinary reference with absent summary is valid"); + + let mut waiver_config = waiver(FINDING_OPENAPI_PUBLIC, "2099-01-01"); + waiver_config.summary = + Some("Public API catalog approved in the operations ticket".to_string()); + let config = DeploymentConfig { + profile: Some(DeploymentProfile::HostedLab), + multi_instance: false, + waivers: vec![waiver_config], + evidence: DeploymentEvidenceConfig::default(), + }; + config.validate().expect("ordinary short summary is valid"); + } + + #[test] + fn validate_rejects_invalid_waiver_metadata_with_field_and_limit() { + let mut cases = Vec::new(); + for reference in ["", " OPS-42", "OPS/42"] { + let mut waiver_config = waiver(FINDING_OPENAPI_PUBLIC, "2099-01-01"); + waiver_config.reference = reference.to_string(); + cases.push((waiver_config, "reference", "128")); + } + let mut waiver_config = waiver(FINDING_OPENAPI_PUBLIC, "2099-01-01"); + waiver_config.reference = "x".repeat(129); + cases.push((waiver_config, "reference", "128")); + + for summary in [ + "", + " summary", + "summary\ncontinued", + "Bearer credential-value", + "Basic credential-value", + "rotated leaked Bearer abcdef", + "-----BEGIN OPENSSH PRIVATE KEY-----", + concat!("-----BEGIN PGP PRIVATE KEY ", "BLOCK-----"), + ] { + let mut waiver_config = waiver(FINDING_OPENAPI_PUBLIC, "2099-01-01"); + waiver_config.summary = Some(summary.to_string()); + cases.push((waiver_config, "summary", "256")); + } + let mut waiver_config = waiver(FINDING_OPENAPI_PUBLIC, "2099-01-01"); + waiver_config.summary = Some("x".repeat(257)); + cases.push((waiver_config, "summary", "256")); + + for (waiver, field, limit) in cases { + let config = DeploymentConfig { + profile: Some(DeploymentProfile::HostedLab), + multi_instance: false, + waivers: vec![waiver], + evidence: DeploymentEvidenceConfig::default(), + }; + let error = config.validate().expect_err("invalid metadata rejected"); + let rendered = error.to_string(); + assert!(rendered.contains(&format!("deployment.waivers[0].{field}"))); + assert!(rendered.contains(limit), "limit missing from: {rendered}"); + } + } + #[test] fn validate_rejects_missing_or_malformed_expiry() { let config = DeploymentConfig { diff --git a/crates/registry-notary-core/src/model.rs b/crates/registry-notary-core/src/model.rs index 2ea27daab..2805e2071 100644 --- a/crates/registry-notary-core/src/model.rs +++ b/crates/registry-notary-core/src/model.rs @@ -1378,6 +1378,7 @@ pub struct EvidenceFormat { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct ClaimResultView { pub evaluation_id: String, pub claim_id: String, @@ -1398,6 +1399,7 @@ pub struct ClaimResultView { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct TargetRefView { #[serde(rename = "type", default, skip_serializing_if = "String::is_empty")] pub entity_type: String, @@ -1409,6 +1411,7 @@ pub struct TargetRefView { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct EvidenceEntityRef { #[serde(rename = "type")] pub entity_type: String, @@ -1435,6 +1438,7 @@ pub const PROVENANCE_GENERATED_BY_CLAIM_EVALUATION: &str = "claim_evaluation"; /// Requester-side identity (client, actor, subject) is deliberately absent; /// those live in restricted audit, never on the public wire. #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct ClaimProvenance { pub schema_version: String, pub generated_by: ProvenanceGeneratedBy, @@ -1481,6 +1485,7 @@ impl ClaimProvenance { /// `policy_id` here names the *evaluation* policy under which the result was /// produced. #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct ProvenanceGeneratedBy { #[serde(rename = "type")] pub entry_type: String, @@ -1512,6 +1517,7 @@ pub struct ProvenanceGeneratedBy { /// The consumed side of a claim provenance record: how many Relay consultations /// contributed to the claim. #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct ProvenanceUsed { pub relay_consultation_count: usize, } diff --git a/crates/registry-notary-server/src/openapi.rs b/crates/registry-notary-server/src/openapi.rs index 994d59313..229730e61 100644 --- a/crates/registry-notary-server/src/openapi.rs +++ b/crates/registry-notary-server/src/openapi.rs @@ -2155,6 +2155,11 @@ fn claim_result_view_schema() -> Value { }, "satisfied": { "type": ["boolean", "null"] }, "disclosure": { "type": "string" }, + "redacted_fields": { + "type": "array", + "items": { "type": "string" }, + "description": "Claim or top-level field names redacted from the public result. Omitted when no redaction was applied." + }, "format": { "type": "string" }, "issued_at": { "type": "string", "format": "date-time" }, "expires_at": { "type": ["string", "null"], "format": "date-time" }, @@ -4049,6 +4054,15 @@ mod tests { doc["components"]["schemas"]["BatchClaimResultView"]["properties"]["value"]["type"], json!(["object", "array", "string", "number", "integer", "boolean", "null"]) ); + assert_eq!( + doc["components"]["schemas"]["ClaimResultView"]["properties"]["redacted_fields"] + ["items"]["type"], + json!("string") + ); + assert!(!doc["components"]["schemas"]["ClaimResultView"]["required"] + .as_array() + .expect("required claim-result fields are an array") + .contains(&json!("redacted_fields"))); let evaluate_request = &doc["components"]["schemas"]["EvaluateRequest"]["properties"]; assert!(evaluate_request.get("subject").is_none()); diff --git a/crates/registry-notary-server/src/posture.rs b/crates/registry-notary-server/src/posture.rs index 54c6b758e..fd75fbe3b 100644 --- a/crates/registry-notary-server/src/posture.rs +++ b/crates/registry-notary-server/src/posture.rs @@ -558,8 +558,9 @@ fn audit_posture( /// Render the operator-declared deployment profile, gate findings, and active /// waivers as the posture `deployment` object. The default tier strips waiver -/// reasons via the allowlist; this object carries them so the restricted tier -/// can surface them to Trust Operations. +/// metadata via the allowlist; this object carries it so the restricted tier +/// can surface the validated reference and optional summary to Trust +/// Operations. fn deployment_object(gates: &DeploymentGateState) -> Value { let mut object = Map::new(); if let Some(profile) = gates.profile { @@ -574,13 +575,7 @@ fn deployment_object(gates: &DeploymentGateState) -> Value { entry.insert("severity".to_string(), json!(finding.severity.as_str())); entry.insert("status".to_string(), json!(finding.status.as_str())); if let Some(waiver) = &finding.waiver { - entry.insert( - "waiver".to_string(), - json!({ - "reason": waiver.reason, - "expires": waiver.expires, - }), - ); + entry.insert("waiver".to_string(), evaluated_waiver_metadata_json(waiver)); } Value::Object(entry) }) @@ -589,18 +584,33 @@ fn deployment_object(gates: &DeploymentGateState) -> Value { let waivers = gates .active_waivers .iter() - .map(|waiver| { - json!({ - "finding": waiver.finding, - "reason": waiver.reason, - "expires": waiver.expires, - }) - }) + .map(evaluated_waiver_json) .collect::>(); object.insert("waivers".to_string(), Value::Array(waivers)); Value::Object(object) } +fn evaluated_waiver_metadata_json( + waiver: ®istry_notary_core::deployment::EvaluatedWaiver, +) -> Value { + let mut object = Map::new(); + object.insert("reference".to_string(), json!(waiver.reference)); + if let Some(summary) = &waiver.summary { + object.insert("summary".to_string(), json!(summary)); + } + object.insert("expires".to_string(), json!(waiver.expires)); + Value::Object(object) +} + +fn evaluated_waiver_json(waiver: ®istry_notary_core::deployment::EvaluatedWaiver) -> Value { + let mut object = evaluated_waiver_metadata_json(waiver) + .as_object() + .expect("waiver metadata renders as an object") + .clone(); + object.insert("finding".to_string(), json!(waiver.finding)); + Value::Object(object) +} + /// Render the audit assurance object: eight separate facts so "audit exists" /// cannot be overclaimed. Protected routes fail closed once a hash secret is /// configured; the keyed integrity is HMAC whenever that secret is present. diff --git a/crates/registry-notary-server/src/relay_client.rs b/crates/registry-notary-server/src/relay_client.rs index 8b7763771..23a943e47 100644 --- a/crates/registry-notary-server/src/relay_client.rs +++ b/crates/registry-notary-server/src/relay_client.rs @@ -1010,21 +1010,6 @@ fn result_decoder(profile: &VerifiedRelayProfile) -> Result Result { if generation_id.is_empty() { @@ -1352,12 +1325,6 @@ impl FieldCursor { .ok_or(self.error) } - fn require_null(&mut self) -> Result<(), RelayClientError> { - matches!(self.scalar()?, ProjectedJsonScalar::Null) - .then_some(()) - .ok_or(self.error) - } - fn exhausted(&self) -> bool { self.fields.as_slice().is_empty() } diff --git a/crates/registry-notary-server/src/relay_client/tests.rs b/crates/registry-notary-server/src/relay_client/tests.rs index 983614a1d..c149dc5ff 100644 --- a/crates/registry-notary-server/src/relay_client/tests.rs +++ b/crates/registry-notary-server/src/relay_client/tests.rs @@ -553,14 +553,6 @@ fn result_value() -> Value { "integration": { "id": "dhis2.tracker.enrollment-status", "revision": 1 - }, - "consent": { - "outcome": "not_required", - "verifier_id": null, - "verifier_revision": null, - "checked_at": null, - "expires_at": null, - "revocation_status": "not_applicable" } } }) @@ -1380,6 +1372,36 @@ async fn result_union_requires_outputs_only_for_match() { server.shutdown().await; } +#[tokio::test] +async fn result_rejects_retired_provenance_consent_member() { + let token_file = TestTokenFile::new(&test_token()); + let mut result = result_value(); + assert!(result["provenance"].get("consent").is_none()); + result["provenance"]["consent"] = json!({ + "outcome": "not_required", + "verifier_id": null, + "verifier_revision": null, + "checked_at": null, + "expires_at": null, + "revocation_status": "not_applicable" + }); + let server = FakeRelay::start( + metadata_response(), + WireResponse::ok(serde_json::to_vec(&result).unwrap()), + ) + .await; + let client = verified(&server, &token_file).await; + + assert_eq!( + client + .execute(EVALUATION_ID, Zeroizing::new(INPUT_VALUE.to_string())) + .await + .expect_err("retired consent member fails closed"), + RelayClientError::InvalidResult + ); + server.shutdown().await; +} + #[tokio::test] async fn batch_execute_propagates_one_bounded_opaque_child_identity() { let token_file = TestTokenFile::new(&test_token()); diff --git a/crates/registry-notary-server/src/standalone/assembly.rs b/crates/registry-notary-server/src/standalone/assembly.rs index 4f0a86ba7..fcbd73c64 100644 --- a/crates/registry-notary-server/src/standalone/assembly.rs +++ b/crates/registry-notary-server/src/standalone/assembly.rs @@ -218,6 +218,7 @@ fn compile_notary_runtime_with_state_override( ) -> Result { config.validate()?; let deployment_gates = DeploymentGateState::evaluate_with_config_source(&config, config_source); + deployment_gates.log_boot_waivers(); deployment_gates.fail_startup_if_blocked()?; let federation_enabled = config.federation.enabled; let http_limits = NotaryHttpLimits { diff --git a/crates/registry-notary-server/src/standalone/deployment.rs b/crates/registry-notary-server/src/standalone/deployment.rs index 7928fb3bd..6b9a9a808 100644 --- a/crates/registry-notary-server/src/standalone/deployment.rs +++ b/crates/registry-notary-server/src/standalone/deployment.rs @@ -89,6 +89,36 @@ impl DeploymentGateState { }) } + /// Emit one boot warning for every active or expired waiver. Metadata has + /// already passed the shared operations validator, so logs expose only the + /// required reference, optional summary, and expiry. + pub(super) fn log_boot_waivers(&self) { + for finding in &self.findings { + let Some(waiver) = &finding.waiver else { + continue; + }; + if finding.status == DeploymentFindingStatus::Waived { + tracing::warn!( + code = "deployment.gate_waived", + finding = %finding.id, + reference = %waiver.reference, + summary = ?waiver.summary, + expires = %waiver.expires, + "deployment gate finding is suppressed by an active waiver" + ); + } else if finding.id == FINDING_WAIVER_EXPIRED { + tracing::warn!( + code = "deployment.waiver_expired", + finding = %waiver.finding, + reference = %waiver.reference, + summary = ?waiver.summary, + expires = %waiver.expires, + "deployment waiver is expired; its gate binds again" + ); + } + } + } + /// True when a profile is declared, so its gates participate in readiness. /// Runtime compilation refuses undeclared profiles before readiness is served. pub(crate) fn is_bound(&self) -> bool { diff --git a/crates/registry-notary-server/src/standalone/runtime.rs b/crates/registry-notary-server/src/standalone/runtime.rs index 44d949fc1..96bdfac99 100644 --- a/crates/registry-notary-server/src/standalone/runtime.rs +++ b/crates/registry-notary-server/src/standalone/runtime.rs @@ -24,7 +24,8 @@ use base64::engine::general_purpose::URL_SAFE_NO_PAD as BASE64_URL_SAFE_NO_PAD; use base64::Engine as _; use jsonwebtoken::Algorithm; use registry_notary_core::deployment::{ - evaluate_gates, EvaluatedFinding, EvaluatedWaiver, GateEvaluation, + evaluate_gates, DeploymentFindingStatus, EvaluatedFinding, EvaluatedWaiver, GateEvaluation, + FINDING_WAIVER_EXPIRED, }; use registry_notary_core::sd_jwt::EvidenceIssuer; use registry_notary_core::{ diff --git a/crates/registry-notary-server/src/standalone/tests/deployment_gates.rs b/crates/registry-notary-server/src/standalone/tests/deployment_gates.rs index 37542f123..3d9a7e090 100644 --- a/crates/registry-notary-server/src/standalone/tests/deployment_gates.rs +++ b/crates/registry-notary-server/src/standalone/tests/deployment_gates.rs @@ -428,6 +428,10 @@ fn assert_matches_posture_schema(body: &Value) { } async fn fetch_posture(config: StandaloneRegistryNotaryConfig) -> Value { + fetch_posture_for_tier(config, "restricted").await +} + +async fn fetch_posture_for_tier(config: StandaloneRegistryNotaryConfig, tier: &str) -> Value { let mut config = config; config.server.admin_listener.mode = RegistryNotaryAdminListenerMode::SharedWithPublic; add_ops_read_api_key(&mut config); @@ -439,13 +443,39 @@ async fn fetch_posture(config: StandaloneRegistryNotaryConfig) -> Value { let app = notary_shared_router_from_runtime(runtime).expect("activated runtime is serve-ready"); let server = TestServer::builder().http_transport().build(app); let response = server - .get("/admin/v1/posture?tier=restricted") + .get(&format!("/admin/v1/posture?tier={tier}")) .add_header("x-api-key", "ops-token") .await; response.assert_status_ok(); response.json() } +async fn fetch_default_and_restricted_posture( + config: StandaloneRegistryNotaryConfig, +) -> (Value, Value) { + let mut config = config; + config.server.admin_listener.mode = RegistryNotaryAdminListenerMode::SharedWithPublic; + add_ops_read_api_key(&mut config); + let runtime = compile_notary_runtime(config) + .expect("runtime compiles for posture") + .activate() + .await + .expect("source-free runtime activates"); + let app = notary_shared_router_from_runtime(runtime).expect("activated runtime is serve-ready"); + let server = TestServer::builder().http_transport().build(app); + let default = server + .get("/admin/v1/posture?tier=default") + .add_header("x-api-key", "ops-token") + .await; + default.assert_status_ok(); + let restricted = server + .get("/admin/v1/posture?tier=restricted") + .add_header("x-api-key", "ops-token") + .await; + restricted.assert_status_ok(); + (default.json(), restricted.json()) +} + fn audit_path(tmp: &tempfile::TempDir) -> String { tmp.path() .join("audit.jsonl") @@ -670,7 +700,7 @@ fn waiver_for_startup_fail_gate_is_rejected_at_load() { let config = ConfigBuilder::new(&audit_path(&tmp)) .durable_audit(false) .deployment( - "deployment:\n profile: production\n waivers:\n - finding: notary.audit.sink_missing\n reason: \"synthetic test waiver\"\n expires: 2999-01-01\n", + "deployment:\n profile: production\n waivers:\n - finding: notary.audit.sink_missing\n reference: OPS-TEST-HARD-GATE\n expires: 2999-01-01\n", ) .build(); @@ -1197,11 +1227,16 @@ async fn posture_reports_waived_finding_with_active_waiver() { let config = ConfigBuilder::new(&audit_path(&tmp)) .openapi_public(true) .deployment( - "deployment:\n profile: hosted_lab\n waivers:\n - finding: notary.openapi.public\n reason: \"synthetic single-node lab, ticket TEST-1\"\n expires: 2999-01-01\n", + "deployment:\n profile: hosted_lab\n waivers:\n - finding: notary.openapi.public\n reference: OPS-TEST-POSTURE\n summary: \"Synthetic single-node lab\"\n expires: 2999-01-01\n", ) .build(); - let posture = fetch_posture(config).await; + let (default, posture) = fetch_default_and_restricted_posture(config).await; + let default_rendered = default.to_string(); + assert!(!default_rendered.contains("OPS-TEST-POSTURE")); + assert!(!default_rendered.contains("Synthetic single-node lab")); + assert!(default["deployment"].get("waivers").is_none()); + assert_matches_posture_schema(&posture); let findings = posture["deployment"]["findings"] @@ -1212,17 +1247,21 @@ async fn posture_reports_waived_finding_with_active_waiver() { .find(|finding| finding["id"] == "notary.openapi.public") .expect("public OpenAPI finding is present"); assert_eq!(waived["status"], "waived"); + assert_eq!(waived["waiver"]["reference"], "OPS-TEST-POSTURE"); + assert_eq!(waived["waiver"]["summary"], "Synthetic single-node lab"); assert_eq!(waived["waiver"]["expires"], "2999-01-01"); + assert!(waived["waiver"].get("reason").is_none()); let waivers = posture["deployment"]["waivers"] .as_array() .expect("deployment waivers is an array"); - assert!( - waivers - .iter() - .any(|waiver| waiver["finding"] == "notary.openapi.public"), - "active waiver must be echoed in: {waivers:#?}" - ); + let active = waivers + .iter() + .find(|waiver| waiver["finding"] == "notary.openapi.public") + .unwrap_or_else(|| panic!("active waiver must be echoed in: {waivers:#?}")); + assert_eq!(active["reference"], "OPS-TEST-POSTURE"); + assert_eq!(active["summary"], "Synthetic single-node lab"); + assert!(active.get("reason").is_none()); } #[tokio::test] @@ -1231,7 +1270,7 @@ async fn posture_re_triggers_expired_waiver() { let config = ConfigBuilder::new(&audit_path(&tmp)) .openapi_public(true) .deployment( - "deployment:\n profile: hosted_lab\n waivers:\n - finding: notary.openapi.public\n reason: \"synthetic expired waiver\"\n expires: 2000-01-01\n", + "deployment:\n profile: hosted_lab\n waivers:\n - finding: notary.openapi.public\n reference: OPS-TEST-EXPIRED\n expires: 2000-01-01\n", ) .build(); @@ -1618,7 +1657,8 @@ fn retention_local_only_waiver_is_honored_under_production() { }; let waiver = DeploymentWaiverConfig { finding: FINDING_AUDIT_RETENTION_LOCAL_ONLY.to_string(), - reason: "synthetic test waiver, ticket TEST-1".to_string(), + reference: "OPS-TEST-RETENTION".to_string(), + summary: Some("Synthetic test waiver".to_string()), expires: "2999-01-01".to_string(), }; let evaluation = evaluate_gates( diff --git a/crates/registry-notary-server/tests/standalone_http/support.rs b/crates/registry-notary-server/tests/standalone_http/support.rs index 0097914b7..e68d860a9 100644 --- a/crates/registry-notary-server/tests/standalone_http/support.rs +++ b/crates/registry-notary-server/tests/standalone_http/support.rs @@ -168,14 +168,6 @@ async fn test_relay_execute(State(state): State, request: Reques "integration": { "id": "dhis2.tracker.enrollment-status", "revision": 1 - }, - "consent": { - "outcome": "not_required", - "verifier_id": null, - "verifier_revision": null, - "checked_at": null, - "expires_at": null, - "revocation_status": "not_applicable" } } })) diff --git a/crates/registry-notary/tests/config_schema.rs b/crates/registry-notary/tests/config_schema.rs index 8643a363d..220bbedd3 100644 --- a/crates/registry-notary/tests/config_schema.rs +++ b/crates/registry-notary/tests/config_schema.rs @@ -11,6 +11,10 @@ use registry_notary_core::{ ClaimEvidenceMode, SigningKeyProviderConfig, SigningKeyStatus, StandaloneRegistryNotaryConfig, }; use registry_platform_authcommon::CredentialFingerprintProvider; +use registry_platform_ops::{ + deployment_waiver_reference_schema_fragment, deployment_waiver_summary_schema_fragment, + validate_deployment_waiver_metadata, DeploymentWaiverMetadataError, +}; use serde_json::{json, Value}; const SCHEMA_ARTIFACT: &str = "schemas/registry-notary.config.schema.json"; @@ -67,6 +71,17 @@ fn assert_runtime_rejects(instance: &Value, label: &str) { ); } +fn assert_runtime_load_rejects(instance: &Value, label: &str) { + let yaml = serde_norway::to_string(instance) + .unwrap_or_else(|error| panic!("failed to serialize {label} as YAML: {error}")); + if let Ok(config) = serde_norway::from_str::(&yaml) { + assert!( + config.validate().is_err(), + "{label} must be rejected during runtime validation" + ); + } +} + fn maintained_runtime_fixtures() -> Vec { let profiles = stack_root().join("crates/registry-relay/profiles"); let mut fixtures = fs::read_dir(profiles) @@ -85,6 +100,23 @@ fn example_config() -> Value { )) } +fn config_with_deployment_waiver(reference: &str, summary: Option<&str>) -> Value { + let mut config = example_config(); + let mut waiver = json!({ + "finding": "notary.openapi.public", + "reference": reference, + "expires": "2999-01-01" + }); + if let Some(summary) = summary { + waiver["summary"] = json!(summary); + } + config["deployment"] = json!({ + "profile": "hosted_lab", + "waivers": [waiver] + }); + config +} + fn with_authorization_details(mut config: Value) -> Value { config["auth"]["api_keys"][0]["authorization_details"] = json!({ "type": "registry_notary_authorization", @@ -297,6 +329,125 @@ fn strict_nested_objects_and_tagged_variants_match_runtime_deserialization() { assert_runtime_rejects(&unknown_signing_status, "unknown signing-key status"); } +#[test] +fn deployment_waiver_schema_rejects_retired_and_noncanonical_metadata() { + let schema = document(); + let mut config = example_config(); + config["deployment"] = json!({ + "profile": "hosted_lab", + "waivers": [{ + "finding": "notary.openapi.public", + "reference": "OPS..42", + "expires": "2999-01-01" + }] + }); + assert_invalid(&schema, &config, "waiver reference containing '..'"); + + config["deployment"]["waivers"][0]["reference"] = json!("OPS-42"); + config["deployment"]["waivers"][0]["summary"] = Value::Null; + assert_invalid(&schema, &config, "null deployment waiver summary"); + assert_runtime_load_rejects(&config, "null deployment waiver summary"); + + config["deployment"]["waivers"][0] + .as_object_mut() + .expect("waiver is an object") + .remove("summary"); + config["deployment"]["waivers"][0]["reason"] = json!("retired waiver text"); + assert_invalid(&schema, &config, "retired deployment waiver reason"); + assert_runtime_load_rejects(&config, "retired deployment waiver reason"); +} + +#[test] +fn deployment_waiver_schema_matches_shared_portable_metadata_contract() { + let schema = document(); + assert_eq!( + schema.pointer("/$defs/DeploymentWaiverReference"), + Some(&deployment_waiver_reference_schema_fragment()) + ); + assert_eq!( + schema.pointer("/$defs/DeploymentWaiverSummary"), + Some(&deployment_waiver_summary_schema_fragment()) + ); + + for reference in ["OPS-42", "Bearer:", "Authorization:Basic:"] { + validate_deployment_waiver_metadata(reference, None).unwrap_or_else(|error| { + panic!("runtime rejected valid reference {reference:?}: {error}") + }); + assert_valid( + &schema, + &config_with_deployment_waiver(reference, None), + &format!("portable deployment waiver reference {reference:?}"), + ); + } + for reference in ["Bearer:abcdef", "authorization:bAsIc:abc123", "Bearer::"] { + assert_eq!( + validate_deployment_waiver_metadata(reference, None), + Err(DeploymentWaiverMetadataError::ReferenceCredentialLiteral) + ); + assert_invalid( + &schema, + &config_with_deployment_waiver(reference, None), + &format!("credential-shaped deployment waiver reference {reference:?}"), + ); + } + + for summary in [ + "Ordinary operator summary".to_string(), + "\u{feff}summary\u{feff}".to_string(), + "summary\u{2028}continued".to_string(), + "é".repeat(256), + ] { + validate_deployment_waiver_metadata("OPS-42", Some(&summary)) + .unwrap_or_else(|error| panic!("runtime rejected valid summary {summary:?}: {error}")); + assert_valid( + &schema, + &config_with_deployment_waiver("OPS-42", Some(&summary)), + "structurally valid deployment waiver summary", + ); + } + for summary in [ + String::new(), + " summary".to_string(), + "summary\u{3000}".to_string(), + "summary\u{001f}continued".to_string(), + "é".repeat(257), + ] { + assert!( + validate_deployment_waiver_metadata("OPS-42", Some(&summary)).is_err(), + "runtime must reject structurally invalid summary {summary:?}" + ); + assert_invalid( + &schema, + &config_with_deployment_waiver("OPS-42", Some(&summary)), + "structurally invalid deployment waiver summary", + ); + } + + for summary in [ + "Authorization: "Bearer abcdef"", + concat!("accidentally pasted -----BEGIN PRIVATE ", "KEY-----"), + concat!( + "accidentally pasted -----BEGIN PGP PRIVATE KEY ", + "BLOCK-----" + ), + ] { + assert_eq!( + validate_deployment_waiver_metadata("OPS-42", Some(summary)), + Err(DeploymentWaiverMetadataError::SummaryCredentialLiteral) + ); + let config = config_with_deployment_waiver("OPS-42", Some(summary)); + assert_valid( + &schema, + &config, + "contextual waiver summary left to semantic validation", + ); + assert_runtime_load_rejects( + &config, + "contextual waiver summary rejected by semantic validation", + ); + } +} + #[test] fn authorization_details_preserve_extension_compatibility_at_every_policy_level() { let schema = document(); diff --git a/crates/registry-platform-ops/fixtures/posture/default-allowlist.json b/crates/registry-platform-ops/fixtures/posture/default-allowlist.json index 12fa226e3..c4a251ebf 100644 --- a/crates/registry-platform-ops/fixtures/posture/default-allowlist.json +++ b/crates/registry-platform-ops/fixtures/posture/default-allowlist.json @@ -112,8 +112,10 @@ "/configuration/trusted_roots", "/standards_artifacts/*/url", "/relay/refresh_health", - "/deployment/findings/*/waiver/reason", - "/deployment/waivers/*/reason", + "/deployment/findings/*/waiver/reference", + "/deployment/findings/*/waiver/summary", + "/deployment/waivers/*/reference", + "/deployment/waivers/*/summary", "/notary/signing_keys", "/notary/federation/node_id", "/notary/federation/issuer", diff --git a/crates/registry-platform-ops/fixtures/posture/restricted-posture.valid.json b/crates/registry-platform-ops/fixtures/posture/restricted-posture.valid.json index 86883d37b..f753357fc 100644 --- a/crates/registry-platform-ops/fixtures/posture/restricted-posture.valid.json +++ b/crates/registry-platform-ops/fixtures/posture/restricted-posture.valid.json @@ -55,7 +55,8 @@ "severity": "finding_warn", "status": "waived", "waiver": { - "reason": "temporary maintenance window approved by operations", + "reference": "OPS-2026-0042", + "summary": "Temporary maintenance window approved by operations", "expires": "2026-07-01" } } @@ -63,7 +64,8 @@ "waivers": [ { "finding": "readiness.cache.degraded", - "reason": "temporary maintenance window approved by operations", + "reference": "OPS-2026-0042", + "summary": "Temporary maintenance window approved by operations", "expires": "2026-07-01" } ] diff --git a/crates/registry-platform-ops/schemas/registry.ops.posture.v1.schema.json b/crates/registry-platform-ops/schemas/registry.ops.posture.v1.schema.json index 96cc914a0..24a66a7cd 100644 --- a/crates/registry-platform-ops/schemas/registry.ops.posture.v1.schema.json +++ b/crates/registry-platform-ops/schemas/registry.ops.posture.v1.schema.json @@ -400,6 +400,39 @@ "waived" ] }, + "waiver_reference": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!.*\\.\\.)[A-Za-z0-9._:-]+$", + "not": { + "pattern": "^(?:[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]:)?(?:[Bb][Ee][Aa][Rr][Ee][Rr]|[Bb][Aa][Ss][Ii][Cc]):[A-Za-z0-9._:-]+$" + } + }, + "waiver_summary": { + "description": "Structurally valid deployment-waiver summary. Contextual authorization-value and private-key marker exclusions require semantic producer validation.", + "$comment": "Schema acceptance does not replace validate_deployment_waiver_metadata.", + "type": "string", + "minLength": 1, + "maxLength": 256, + "allOf": [ + { + "not": { + "pattern": "[\\u0000-\\u001F\\u007F-\\u009F]" + } + }, + { + "not": { + "pattern": "^[\\u0009-\\u000D\\u0020\\u0085\\u00A0\\u1680\\u2000-\\u200A\\u2028\\u2029\\u202F\\u205F\\u3000]" + } + }, + { + "not": { + "pattern": "[\\u0009-\\u000D\\u0020\\u0085\\u00A0\\u1680\\u2000-\\u200A\\u2028\\u2029\\u202F\\u205F\\u3000]$" + } + } + ] + }, "deployment_finding": { "type": "object", "additionalProperties": false, @@ -427,13 +460,15 @@ "type": "object", "additionalProperties": false, "required": [ - "reason", + "reference", "expires" ], "properties": { - "reason": { - "type": "string", - "minLength": 1 + "reference": { + "$ref": "#/$defs/waiver_reference" + }, + "summary": { + "$ref": "#/$defs/waiver_summary" }, "expires": { "type": "string", @@ -447,16 +482,18 @@ "additionalProperties": false, "required": [ "finding", - "reason", + "reference", "expires" ], "properties": { "finding": { "$ref": "#/$defs/finding_id" }, - "reason": { - "type": "string", - "minLength": 1 + "reference": { + "$ref": "#/$defs/waiver_reference" + }, + "summary": { + "$ref": "#/$defs/waiver_summary" }, "expires": { "type": "string", diff --git a/crates/registry-platform-ops/src/lib.rs b/crates/registry-platform-ops/src/lib.rs index 26c90c749..18b71756b 100644 --- a/crates/registry-platform-ops/src/lib.rs +++ b/crates/registry-platform-ops/src/lib.rs @@ -25,6 +25,56 @@ use rustix::fs::{Mode, OFlags}; pub const POSTURE_SCHEMA_V1: &str = include_str!("../schemas/registry.ops.posture.v1.schema.json"); +/// Return the portable JSON Schema fragment for deployment-waiver references. +/// +/// Runtime validation remains authoritative, including the deliberate +/// acceptance of an empty suffix after a reserved authorization scheme. +#[must_use] +pub fn deployment_waiver_reference_schema_fragment() -> Value { + serde_json::json!({ + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": r"^(?!.*\.\.)[A-Za-z0-9._:-]+$", + "not": { + "pattern": r"^(?:[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]:)?(?:[Bb][Ee][Aa][Rr][Ee][Rr]|[Bb][Aa][Ss][Ii][Cc]):[A-Za-z0-9._:-]+$" + } + }) +} + +/// Return the portable structural JSON Schema fragment for waiver summaries. +/// +/// Contextual authorization-value and private-key marker exclusions require +/// [`validate_deployment_waiver_metadata`]; schema acceptance alone is not +/// sufficient semantic producer validation. +#[must_use] +pub fn deployment_waiver_summary_schema_fragment() -> Value { + serde_json::json!({ + "description": "Structurally valid deployment-waiver summary. Contextual authorization-value and private-key marker exclusions require semantic producer validation.", + "$comment": "Schema acceptance does not replace validate_deployment_waiver_metadata.", + "type": "string", + "minLength": 1, + "maxLength": 256, + "allOf": [ + { + "not": { + "pattern": r"[\u0000-\u001F\u007F-\u009F]" + } + }, + { + "not": { + "pattern": r"^[\u0009-\u000D\u0020\u0085\u00A0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000]" + } + }, + { + "not": { + "pattern": r"[\u0009-\u000D\u0020\u0085\u00A0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000]$" + } + } + ] + }) +} + pub const ADMIN_ERROR_SCHEMA_V1: &str = include_str!("../schemas/registry.admin.error.v1.schema.json"); @@ -126,11 +176,41 @@ impl DeploymentFindingStatus { } #[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(try_from = "DeploymentFindingWaiverDocument")] pub struct DeploymentFindingWaiver { - pub reason: String, + pub reference: String, + #[serde( + default, + deserialize_with = "deserialize_optional_deployment_waiver_summary", + skip_serializing_if = "Option::is_none" + )] + pub summary: Option, pub expires: String, } +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct DeploymentFindingWaiverDocument { + reference: String, + #[serde(default)] + summary: OptionalDeploymentWaiverSummary, + expires: String, +} + +impl TryFrom for DeploymentFindingWaiver { + type Error = DeploymentWaiverMetadataError; + + fn try_from(value: DeploymentFindingWaiverDocument) -> Result { + let summary: Option = value.summary.into(); + validate_deployment_waiver_metadata(&value.reference, summary.as_deref())?; + Ok(Self { + reference: value.reference, + summary, + expires: value.expires, + }) + } +} + #[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] pub struct DeploymentFinding { pub id: String, @@ -141,12 +221,44 @@ pub struct DeploymentFinding { } #[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(try_from = "DeploymentWaiverDocument")] pub struct DeploymentWaiver { pub finding: String, - pub reason: String, + pub reference: String, + #[serde( + default, + deserialize_with = "deserialize_optional_deployment_waiver_summary", + skip_serializing_if = "Option::is_none" + )] + pub summary: Option, pub expires: String, } +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct DeploymentWaiverDocument { + finding: String, + reference: String, + #[serde(default)] + summary: OptionalDeploymentWaiverSummary, + expires: String, +} + +impl TryFrom for DeploymentWaiver { + type Error = DeploymentWaiverMetadataError; + + fn try_from(value: DeploymentWaiverDocument) -> Result { + let summary: Option = value.summary.into(); + validate_deployment_waiver_metadata(&value.reference, summary.as_deref())?; + Ok(Self { + finding: value.finding, + reference: value.reference, + summary, + expires: value.expires, + }) + } +} + #[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] #[serde(rename_all = "snake_case")] #[non_exhaustive] @@ -1044,6 +1156,364 @@ pub fn is_valid_approval_reference(reference: &str) -> bool { .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | ':' | '-')) } +/// Maximum UTF-8 byte length for a deployment-waiver reference. +pub const DEPLOYMENT_WAIVER_REFERENCE_MAX_BYTES: usize = 128; + +/// Maximum Unicode scalar-value count for an optional deployment-waiver summary. +pub const DEPLOYMENT_WAIVER_SUMMARY_MAX_CHARS: usize = 256; + +/// Deserialize an optional waiver summary while distinguishing omission from +/// explicit `null`. The 1.0 contract permits only an omitted field or a string; +/// products reuse this helper so their config parsers cannot drift. +pub fn deserialize_optional_deployment_waiver_summary<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + OptionalDeploymentWaiverSummary::deserialize(deserializer).map(Into::into) +} + +/// Field adapter for optional waiver summaries that rejects explicit `null`. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct OptionalDeploymentWaiverSummary(Option); + +impl From for Option { + fn from(value: OptionalDeploymentWaiverSummary) -> Self { + value.0 + } +} + +impl<'de> Deserialize<'de> for OptionalDeploymentWaiverSummary { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + struct OptionalSummaryVisitor; + + impl<'de> serde::de::Visitor<'de> for OptionalSummaryVisitor { + type Value = OptionalDeploymentWaiverSummary; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("an omitted deployment waiver summary or a non-null string") + } + + fn visit_none(self) -> Result + where + E: serde::de::Error, + { + Err(E::invalid_type(serde::de::Unexpected::Unit, &self)) + } + + fn visit_unit(self) -> Result + where + E: serde::de::Error, + { + Err(E::invalid_type(serde::de::Unexpected::Unit, &self)) + } + + fn visit_some(self, deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + String::deserialize(deserializer) + .map(|value| OptionalDeploymentWaiverSummary(Some(value))) + } + + fn visit_str(self, value: &str) -> Result + where + E: serde::de::Error, + { + Ok(OptionalDeploymentWaiverSummary(Some(value.to_string()))) + } + + fn visit_string(self, value: String) -> Result + where + E: serde::de::Error, + { + Ok(OptionalDeploymentWaiverSummary(Some(value))) + } + } + + deserializer.deserialize_option(OptionalSummaryVisitor) + } +} + +/// A field-level deployment-waiver metadata validation failure. +/// +/// Waiver metadata is privileged, reviewed operator configuration, but it is +/// copied into restricted posture, boot logs, screenshots, and support +/// bundles. These narrow checks prevent accidental credential disclosure +/// without applying hostname, URL, JWT, base64, entropy, or key-name +/// heuristics that would reject ordinary operational text. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum DeploymentWaiverMetadataError { + ReferenceLength, + ReferenceNotCanonical, + ReferenceInvalidSyntax, + ReferenceCredentialLiteral, + SummaryLength, + SummaryNotCanonical, + SummaryControlCharacter, + SummaryCredentialLiteral, +} + +impl DeploymentWaiverMetadataError { + pub const fn field(self) -> &'static str { + match self { + Self::ReferenceLength + | Self::ReferenceNotCanonical + | Self::ReferenceInvalidSyntax + | Self::ReferenceCredentialLiteral => "reference", + Self::SummaryLength + | Self::SummaryNotCanonical + | Self::SummaryControlCharacter + | Self::SummaryCredentialLiteral => "summary", + } + } +} + +impl Display for DeploymentWaiverMetadataError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::ReferenceLength => write!( + formatter, + "reference must be 1..={DEPLOYMENT_WAIVER_REFERENCE_MAX_BYTES} bytes" + ), + Self::ReferenceNotCanonical => write!( + formatter, + "reference must be 1..={DEPLOYMENT_WAIVER_REFERENCE_MAX_BYTES} bytes and already canonical without surrounding whitespace" + ), + Self::ReferenceInvalidSyntax => write!( + formatter, + "reference must be 1..={DEPLOYMENT_WAIVER_REFERENCE_MAX_BYTES} bytes and use only [A-Za-z0-9._:-] without '..'" + ), + Self::ReferenceCredentialLiteral => write!( + formatter, + "reference must be 1..={DEPLOYMENT_WAIVER_REFERENCE_MAX_BYTES} bytes and not encode a Bearer or Basic authorization value" + ), + Self::SummaryLength => write!( + formatter, + "summary must be 1..={DEPLOYMENT_WAIVER_SUMMARY_MAX_CHARS} characters when present" + ), + Self::SummaryNotCanonical => write!( + formatter, + "summary must be 1..={DEPLOYMENT_WAIVER_SUMMARY_MAX_CHARS} characters and already trimmed when present" + ), + Self::SummaryControlCharacter => write!( + formatter, + "summary must be 1..={DEPLOYMENT_WAIVER_SUMMARY_MAX_CHARS} characters and contain no control characters when present" + ), + Self::SummaryCredentialLiteral => write!( + formatter, + "summary must be 1..={DEPLOYMENT_WAIVER_SUMMARY_MAX_CHARS} characters and contain no authorization value or private-key begin marker when present" + ), + } + } +} + +impl std::error::Error for DeploymentWaiverMetadataError {} + +/// Validate the cross-product deployment-waiver metadata contract. +pub fn validate_deployment_waiver_metadata( + reference: &str, + summary: Option<&str>, +) -> Result<(), DeploymentWaiverMetadataError> { + if reference.is_empty() || reference.len() > DEPLOYMENT_WAIVER_REFERENCE_MAX_BYTES { + return Err(DeploymentWaiverMetadataError::ReferenceLength); + } + if reference.trim() != reference { + return Err(DeploymentWaiverMetadataError::ReferenceNotCanonical); + } + if !is_valid_approval_reference(reference) { + return Err(DeploymentWaiverMetadataError::ReferenceInvalidSyntax); + } + if contains_reference_authorization_value(reference) { + return Err(DeploymentWaiverMetadataError::ReferenceCredentialLiteral); + } + + let Some(summary) = summary else { + return Ok(()); + }; + if summary.is_empty() || summary.chars().count() > DEPLOYMENT_WAIVER_SUMMARY_MAX_CHARS { + return Err(DeploymentWaiverMetadataError::SummaryLength); + } + if summary.trim() != summary { + return Err(DeploymentWaiverMetadataError::SummaryNotCanonical); + } + if summary.chars().any(char::is_control) { + return Err(DeploymentWaiverMetadataError::SummaryControlCharacter); + } + if contains_high_confidence_credential_literal(summary) { + return Err(DeploymentWaiverMetadataError::SummaryCredentialLiteral); + } + Ok(()) +} + +const AUTHORIZATION_SCHEMES: [&str; 2] = ["Bearer", "Basic"]; + +fn contains_reference_authorization_value(reference: &str) -> bool { + // References are opaque identifiers, not prose. An exact scheme prefix + // plus a nonempty value is therefore a high-confidence pasted credential. + let reference = strip_ascii_case_prefix(reference, "Authorization:").unwrap_or(reference); + AUTHORIZATION_SCHEMES.iter().any(|scheme| { + reference + .get(..scheme.len()) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case(scheme)) + && reference.as_bytes().get(scheme.len()) == Some(&b':') + && reference.len() > scheme.len() + 1 + }) +} + +fn strip_ascii_case_prefix<'a>(value: &'a str, prefix: &str) -> Option<&'a str> { + value + .get(..prefix.len()) + .filter(|candidate| candidate.eq_ignore_ascii_case(prefix)) + .and_then(|_| value.get(prefix.len()..)) +} + +fn contains_high_confidence_credential_literal(summary: &str) -> bool { + AUTHORIZATION_SCHEMES + .iter() + .any(|scheme| contains_authorization_value(summary, scheme)) + || summary.split("-----BEGIN ").skip(1).any(|suffix| { + suffix.split_once("-----").is_some_and(|(label, _)| { + label == "PRIVATE KEY" + || label.ends_with(" PRIVATE KEY") + || label == "PGP PRIVATE KEY BLOCK" + }) + }) +} + +fn contains_authorization_value(summary: &str, scheme: &str) -> bool { + let mut value_follows = false; + let mut authorization_context = false; + let mut authorization_colon_follows = false; + for word in summary.split_whitespace() { + if value_follows { + if is_authorization_separator_token(word) { + continue; + } + value_follows = false; + if is_credential_like_authorization_value(word) { + return true; + } + } + + if is_authorization_separator_token(word) { + if authorization_colon_follows { + authorization_context = word.chars().any(is_authorization_colon); + authorization_colon_follows = false; + } + continue; + } + + let authorization_label = authorization_value_in_word(word, "Authorization"); + if let Some(label) = authorization_label { + authorization_context = label.has_colon; + authorization_colon_follows = !label.has_colon; + } else { + authorization_colon_follows = false; + } + + let Some(authorization_value) = authorization_value_in_word(word, scheme) else { + if authorization_label.is_none() { + authorization_context = false; + } + continue; + }; + if authorization_value.standalone_wrapped && !authorization_context { + authorization_context = false; + continue; + } + + authorization_context = false; + if authorization_value.value.is_empty() { + value_follows = true; + } else if is_credential_like_authorization_value(authorization_value.value) { + return true; + } + } + false +} + +#[derive(Clone, Copy)] +struct AuthorizationValue<'a> { + value: &'a str, + has_colon: bool, + standalone_wrapped: bool, +} + +fn authorization_value_in_word<'a>(word: &'a str, scheme: &str) -> Option> { + let mut segment_start = 0; + for (segment_end, delimiter_len) in word + .char_indices() + .filter_map(|(index, character)| { + is_authorization_colon(character).then_some((index, character.len_utf8())) + }) + .chain(std::iter::once((word.len(), 0))) + { + let segment = word.get(segment_start..segment_end)?; + // Normalize only boundaries around ASCII auth tokens. This covers + // Unicode punctuation without changing internal characters or + // maintaining a partial list of quote and bracket code points. + let normalized_scheme = segment.trim_matches(is_authorization_boundary); + if normalized_scheme.eq_ignore_ascii_case(scheme) { + let has_colon = delimiter_len != 0; + let standalone_wrapped = normalized_scheme.len() < segment.len() + && segment.starts_with(is_authorization_boundary) + && segment.ends_with(is_authorization_boundary); + let value = if has_colon { + word.get(segment_end + delimiter_len..)? + .trim_start_matches(is_authorization_colon) + } else { + "" + }; + return Some(AuthorizationValue { + value, + has_colon, + standalone_wrapped, + }); + } + segment_start = segment_end + delimiter_len; + } + None +} + +fn is_credential_like_authorization_value(candidate: &str) -> bool { + let prose_word = candidate.trim_matches(is_authorization_boundary); + !prose_word.is_empty() + && ![ + "access", + "auth", + "authentication", + "authorization", + "credential", + "credentials", + "header", + "scheme", + "token", + "tokens", + "value", + "values", + ] + .iter() + .any(|word| prose_word.eq_ignore_ascii_case(word)) +} + +fn is_authorization_separator_token(candidate: &str) -> bool { + !candidate.is_empty() && candidate.chars().all(is_authorization_boundary) +} + +fn is_authorization_colon(character: char) -> bool { + matches!(character, ':' | '\u{fe55}' | '\u{ff1a}') +} + +fn is_authorization_boundary(character: char) -> bool { + !character.is_alphanumeric() && !character.is_whitespace() && !character.is_control() +} + #[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] pub struct BreakGlassApproval { pub approved_by: String, @@ -3464,6 +3934,298 @@ mod tests { assert!(!is_valid_approval_reference("ctrl\nchar")); } + #[test] + fn deployment_waiver_metadata_accepts_reference_and_optional_summary() { + validate_deployment_waiver_metadata("OPS-2026:INC_42", None) + .expect("reference without summary is valid"); + for reference in ["OPS-BEARER-42", "BasicAuth:OPS-42", "Bearer-rotation:42"] { + validate_deployment_waiver_metadata(reference, None).unwrap_or_else(|error| { + panic!("ordinary operator reference rejected: {reference:?}: {error}") + }); + } + validate_deployment_waiver_metadata( + "OPS-2026:INC_42", + Some("Gateway rate limiting is tracked in the operations ticket"), + ) + .expect("ordinary short summary is valid"); + } + + #[test] + fn deployment_waiver_metadata_rejects_invalid_or_overlong_reference() { + assert_eq!( + validate_deployment_waiver_metadata("", None), + Err(DeploymentWaiverMetadataError::ReferenceLength) + ); + assert_eq!( + validate_deployment_waiver_metadata(" OPS-42", None), + Err(DeploymentWaiverMetadataError::ReferenceNotCanonical) + ); + assert_eq!( + validate_deployment_waiver_metadata("OPS/42", None), + Err(DeploymentWaiverMetadataError::ReferenceInvalidSyntax) + ); + assert_eq!( + validate_deployment_waiver_metadata("OPS..42", None), + Err(DeploymentWaiverMetadataError::ReferenceInvalidSyntax) + ); + assert_eq!( + validate_deployment_waiver_metadata(&"x".repeat(129), None), + Err(DeploymentWaiverMetadataError::ReferenceLength) + ); + for reference in [ + "Bearer:abcdef", + "bearer:abc123", + "Basic:abc123", + "bAsIc:credential-value", + "Authorization:Bearer:abc123", + "authorization:bAsIc:abc123", + ] { + assert_eq!( + validate_deployment_waiver_metadata(reference, None), + Err(DeploymentWaiverMetadataError::ReferenceCredentialLiteral), + "credential-like reference must be rejected without echoing it: {reference:?}" + ); + } + } + + #[test] + fn deployment_waiver_metadata_rejects_invalid_summary() { + for (summary, expected) in [ + ("", DeploymentWaiverMetadataError::SummaryLength), + ( + " summary", + DeploymentWaiverMetadataError::SummaryNotCanonical, + ), + ( + "summary\ncontinued", + DeploymentWaiverMetadataError::SummaryControlCharacter, + ), + ( + "Bearer credential-value", + DeploymentWaiverMetadataError::SummaryCredentialLiteral, + ), + ( + "Basic credential-value", + DeploymentWaiverMetadataError::SummaryCredentialLiteral, + ), + ( + "bearer credential-value", + DeploymentWaiverMetadataError::SummaryCredentialLiteral, + ), + ( + "basic credential-value", + DeploymentWaiverMetadataError::SummaryCredentialLiteral, + ), + ( + "rotated leaked Bearer abcdef", + DeploymentWaiverMetadataError::SummaryCredentialLiteral, + ), + ( + "copied Basic Zm9vOmJhcg==", + DeploymentWaiverMetadataError::SummaryCredentialLiteral, + ), + ( + "rotated leaked bearer abcdef", + DeploymentWaiverMetadataError::SummaryCredentialLiteral, + ), + ( + "rotated leaked Bearer abcdef before config apply", + DeploymentWaiverMetadataError::SummaryCredentialLiteral, + ), + ( + "copied Basic Zm9vOmJhcg== before config apply", + DeploymentWaiverMetadataError::SummaryCredentialLiteral, + ), + ( + "rotated leaked bearer abcdef before config apply", + DeploymentWaiverMetadataError::SummaryCredentialLiteral, + ), + ( + "Authorization:Bearer abcdef", + DeploymentWaiverMetadataError::SummaryCredentialLiteral, + ), + ( + "Bearer: abcdef", + DeploymentWaiverMetadataError::SummaryCredentialLiteral, + ), + ( + "Authorization:Basic Zm9vOmJhcg== before config apply", + DeploymentWaiverMetadataError::SummaryCredentialLiteral, + ), + ( + "Authorization: (bearer): abcdef after config apply", + DeploymentWaiverMetadataError::SummaryCredentialLiteral, + ), + ( + "Bearer:abcdef before config apply", + DeploymentWaiverMetadataError::SummaryCredentialLiteral, + ), + ( + "Bearer:token:abcdef before config apply", + DeploymentWaiverMetadataError::SummaryCredentialLiteral, + ), + ( + "Authorization: “Bearer abcdef”", + DeploymentWaiverMetadataError::SummaryCredentialLiteral, + ), + ( + "Authorization: "Bearer abcdef"", + DeploymentWaiverMetadataError::SummaryCredentialLiteral, + ), + ( + "Authorization: 「Basic Zm9vOmJhcg==」", + DeploymentWaiverMetadataError::SummaryCredentialLiteral, + ), + ( + "Authorization: “Bearer” abcdef", + DeploymentWaiverMetadataError::SummaryCredentialLiteral, + ), + ( + "Authorization : «Basic» Zm9vOmJhcg==", + DeploymentWaiverMetadataError::SummaryCredentialLiteral, + ), + ( + "Authorization: “Bearer”: supported by the gateway", + DeploymentWaiverMetadataError::SummaryCredentialLiteral, + ), + ( + "Authorization: «Basic»: enabled", + DeploymentWaiverMetadataError::SummaryCredentialLiteral, + ), + ( + "Authorization: (Bearer): API authentication mode", + DeploymentWaiverMetadataError::SummaryCredentialLiteral, + ), + ( + "Bearer : abcdef", + DeploymentWaiverMetadataError::SummaryCredentialLiteral, + ), + ( + "Authorization: «Basic Zm9vOmJhcg==»", + DeploymentWaiverMetadataError::SummaryCredentialLiteral, + ), + ( + "Bearer … abcdef", + DeploymentWaiverMetadataError::SummaryCredentialLiteral, + ), + ( + "Bearer : abcdef", + DeploymentWaiverMetadataError::SummaryCredentialLiteral, + ), + ( + "Bearer:abcdef", + DeploymentWaiverMetadataError::SummaryCredentialLiteral, + ), + ( + concat!("accidentally pasted -----BEGIN PRIVATE ", "KEY-----"), + DeploymentWaiverMetadataError::SummaryCredentialLiteral, + ), + ( + concat!( + "accidentally pasted -----BEGIN OPENSSH PRIVATE ", + "KEY-----" + ), + DeploymentWaiverMetadataError::SummaryCredentialLiteral, + ), + ( + concat!( + "accidentally pasted -----BEGIN ED25519 PRIVATE ", + "KEY-----" + ), + DeploymentWaiverMetadataError::SummaryCredentialLiteral, + ), + ( + concat!( + "accidentally pasted -----BEGIN PGP PRIVATE KEY ", + "BLOCK-----" + ), + DeploymentWaiverMetadataError::SummaryCredentialLiteral, + ), + ] { + assert_eq!( + validate_deployment_waiver_metadata("OPS-42", Some(summary)), + Err(expected), + "summary must be rejected without echoing it: {summary:?}" + ); + } + assert_eq!( + validate_deployment_waiver_metadata("OPS-42", Some(&"x".repeat(257))), + Err(DeploymentWaiverMetadataError::SummaryLength) + ); + validate_deployment_waiver_metadata("OPS-42", Some(&"é".repeat(256))) + .expect("summary limit counts Unicode characters"); + assert_eq!( + validate_deployment_waiver_metadata("OPS-42", Some(&"é".repeat(257))), + Err(DeploymentWaiverMetadataError::SummaryLength), + "summary limit is measured in Unicode characters" + ); + } + + #[test] + fn deployment_waiver_summary_does_not_use_broad_secret_heuristics() { + for summary in [ + "Review URL https://ops.example.test/tickets/42", + "JWT validation follow-up", + "base64 migration review", + "token_name configuration review", + "Bearer credential handling review", + "Basic authentication migration review", + "bearer credential handling review", + "basic authentication migration review", + "Rotated Bearer token handling review", + "Migrated Basic credential storage", + "Bearer credential, handling review before config apply", + "Basic authentication: migration review after config apply", + "Bearer access token handling review", + "Basic auth scheme migration review", + "Bearer header value handling review", + "Authorization: Bearer token handling review", + "Bearer: token handling review", + "Authorization:Basic authentication migration review", + "Bearer-based authentication review", + "Basic-auth migration review", + "Authorization: “Bearer token handling review”", + "Bearer : token handling review", + "Bearer … token handling review", + "“Bearer-based” authentication review", + "Basic:authentication migration review", + "Use “Bearer” for API authentication", + "Document «Basic» for operators", + "Compare "Bearer" and 「Basic」 schemes", + "Document “Bearer”: supported by the gateway", + "Compare «Basic»: enabled", + "Use (Bearer): API authentication mode", + ] { + validate_deployment_waiver_metadata("OPS-42", Some(summary)) + .unwrap_or_else(|error| panic!("ordinary summary rejected: {summary:?}: {error}")); + } + } + + #[test] + fn deployment_waiver_summary_deserializer_rejects_explicit_null() { + #[derive(Deserialize)] + struct Fixture { + #[serde( + default, + deserialize_with = "deserialize_optional_deployment_waiver_summary" + )] + summary: Option, + } + + let omitted: Fixture = + serde_json::from_str(r#"{}"#).expect("omitted optional summary is accepted"); + assert_eq!(omitted.summary, None); + + let present: Fixture = serde_json::from_str(r#"{"summary":"Approved in OPS-42"}"#) + .expect("string summary is accepted"); + assert_eq!(present.summary.as_deref(), Some("Approved in OPS-42")); + + assert!( + serde_json::from_str::(r#"{"summary":null}"#).is_err(), + "explicit null must not be accepted as an omitted summary" + ); + } + #[test] fn bundle_verify_rejection_result_classifies_integrity_failures_as_signature() { assert_eq!( diff --git a/crates/registry-platform-ops/tests/posture_profile_gates_contract.rs b/crates/registry-platform-ops/tests/posture_profile_gates_contract.rs index 068fce006..7e02a3ce3 100644 --- a/crates/registry-platform-ops/tests/posture_profile_gates_contract.rs +++ b/crates/registry-platform-ops/tests/posture_profile_gates_contract.rs @@ -1,8 +1,10 @@ use registry_platform_ops::{ - AuditAnchoring, AuditAssurance, AuditCheckpoints, AuditHashChain, AuditKeyedIntegrity, - AuditRedactionMode, AuditRetentionOwner, AuditSinkClass, AuditWritePolicy, DeploymentFinding, - DeploymentFindingStatus, DeploymentFindingWaiver, DeploymentProfile, DeploymentWaiver, - GateSeverity, POSTURE_SCHEMA_V1, + deployment_waiver_reference_schema_fragment, deployment_waiver_summary_schema_fragment, + validate_deployment_waiver_metadata, AuditAnchoring, AuditAssurance, AuditCheckpoints, + AuditHashChain, AuditKeyedIntegrity, AuditRedactionMode, AuditRetentionOwner, AuditSinkClass, + AuditWritePolicy, DeploymentFinding, DeploymentFindingStatus, DeploymentFindingWaiver, + DeploymentProfile, DeploymentWaiver, DeploymentWaiverMetadataError, GateSeverity, + POSTURE_SCHEMA_V1, }; use serde::de::DeserializeOwned; use serde::Serialize; @@ -57,7 +59,8 @@ fn posture_with_profile_gates() -> Value { "severity": "finding_warn", "status": "waived", "waiver": { - "reason": "temporary maintenance window", + "reference": "OPS-2026-0042", + "summary": "Temporary maintenance window", "expires": "2026-07-01" } } @@ -65,7 +68,8 @@ fn posture_with_profile_gates() -> Value { "waivers": [ { "finding": "readiness.cache.degraded", - "reason": "temporary maintenance window", + "reference": "OPS-2026-0042", + "summary": "Temporary maintenance window", "expires": "2026-07-01" } ] @@ -83,6 +87,18 @@ fn posture_with_profile_gates() -> Value { posture } +const WAIVER_POINTERS: [&str; 2] = ["/deployment/findings/1/waiver", "/deployment/waivers/0"]; + +fn posture_with_waiver_field(waiver_pointer: &str, field: &str, value: Value) -> Value { + let mut posture = posture_with_profile_gates(); + posture + .pointer_mut(waiver_pointer) + .and_then(Value::as_object_mut) + .unwrap_or_else(|| panic!("waiver object exists at {waiver_pointer}")) + .insert(field.to_string(), value); + posture +} + #[test] fn posture_schema_accepts_profile_gates_and_undeclared_profile() { let validator = posture_validator(); @@ -92,6 +108,177 @@ fn posture_schema_accepts_profile_gates_and_undeclared_profile() { assert_valid(&validator, &undeclared); } +#[test] +fn posture_waiver_definitions_share_structural_metadata_contracts() { + let schema = parse(POSTURE_SCHEMA_V1); + assert_eq!( + schema.pointer("/$defs/waiver_reference"), + Some(&deployment_waiver_reference_schema_fragment()) + ); + assert_eq!( + schema.pointer("/$defs/waiver_summary"), + Some(&deployment_waiver_summary_schema_fragment()) + ); + for waiver_definition in ["deployment_finding_waiver", "deployment_waiver"] { + assert_eq!( + schema.pointer(&format!( + "/$defs/{waiver_definition}/properties/reference/$ref" + )), + Some(&json!("#/$defs/waiver_reference")) + ); + assert_eq!( + schema.pointer(&format!( + "/$defs/{waiver_definition}/properties/summary/$ref" + )), + Some(&json!("#/$defs/waiver_summary")) + ); + } +} + +#[test] +fn posture_schema_waiver_reference_matches_runtime_prefix_contract() { + let validator = posture_validator(); + + for reference in [ + "OPS-42", + "Bearer:", + "Basic:", + "Authorization:Bearer:", + "authorization:bAsIc:", + ] { + validate_deployment_waiver_metadata(reference, None).unwrap_or_else(|error| { + panic!("runtime rejected valid reference {reference:?}: {error}") + }); + for waiver_pointer in WAIVER_POINTERS { + assert_valid( + &validator, + &posture_with_waiver_field(waiver_pointer, "reference", json!(reference)), + ); + } + } + + for reference in [ + "Bearer:abcdef", + "bAsIc:credential-value", + "Authorization:Bearer:abc123", + "authorization:bAsIc:abc123", + "Bearer::", + ] { + assert_eq!( + validate_deployment_waiver_metadata(reference, None), + Err(DeploymentWaiverMetadataError::ReferenceCredentialLiteral), + "runtime must reject credential-shaped reference {reference:?}" + ); + for waiver_pointer in WAIVER_POINTERS { + assert_invalid( + &validator, + &posture_with_waiver_field(waiver_pointer, "reference", json!(reference)), + ); + } + } +} + +#[test] +fn posture_schema_waiver_summary_matches_runtime_structural_contract() { + let validator = posture_validator(); + + for summary in [ + "Ordinary operator summary".to_string(), + "\u{feff}summary\u{feff}".to_string(), + "summary\u{2028}continued".to_string(), + "é".repeat(256), + ] { + validate_deployment_waiver_metadata("OPS-42", Some(&summary)) + .unwrap_or_else(|error| panic!("runtime rejected valid summary {summary:?}: {error}")); + for waiver_pointer in WAIVER_POINTERS { + assert_valid( + &validator, + &posture_with_waiver_field(waiver_pointer, "summary", json!(summary)), + ); + } + } + + for summary in [String::new(), "é".repeat(257)] { + assert!( + validate_deployment_waiver_metadata("OPS-42", Some(&summary)).is_err(), + "runtime must reject structurally invalid summary {summary:?}" + ); + for waiver_pointer in WAIVER_POINTERS { + assert_invalid( + &validator, + &posture_with_waiver_field(waiver_pointer, "summary", json!(summary)), + ); + } + } + + for whitespace in (0..=char::MAX as u32) + .filter_map(char::from_u32) + .filter(|character| character.is_whitespace()) + { + for summary in [ + format!("{whitespace}summary"), + format!("summary{whitespace}"), + ] { + assert!( + validate_deployment_waiver_metadata("OPS-42", Some(&summary)).is_err(), + "runtime must reject edge whitespace U+{:04X}", + whitespace as u32 + ); + for waiver_pointer in WAIVER_POINTERS { + assert_invalid( + &validator, + &posture_with_waiver_field(waiver_pointer, "summary", json!(summary)), + ); + } + } + } + + for value in (0..=0x1f).chain(0x7f..=0x9f) { + let control = char::from_u32(value).expect("C0/C1 value is a Unicode scalar"); + assert!(control.is_control()); + let summary = format!("summary{control}continued"); + assert_eq!( + validate_deployment_waiver_metadata("OPS-42", Some(&summary)), + Err(DeploymentWaiverMetadataError::SummaryControlCharacter), + "runtime must reject control U+{value:04X}" + ); + for waiver_pointer in WAIVER_POINTERS { + assert_invalid( + &validator, + &posture_with_waiver_field(waiver_pointer, "summary", json!(summary)), + ); + } + } +} + +#[test] +fn posture_schema_summary_acceptance_does_not_replace_semantic_validation() { + let validator = posture_validator(); + + // JSON Schema stops at portable structural rules. Producers must also use + // validate_deployment_waiver_metadata for contextual credential and + // private-key marker rejection before emitting either waiver shape. + for summary in [ + "Authorization: "Bearer abcdef"", + concat!("accidentally pasted -----BEGIN PRIVATE ", "KEY-----"), + concat!( + "accidentally pasted -----BEGIN PGP PRIVATE KEY ", + "BLOCK-----" + ), + ] { + assert_eq!( + validate_deployment_waiver_metadata("OPS-42", Some(summary)), + Err(DeploymentWaiverMetadataError::SummaryCredentialLiteral) + ); + for waiver_pointer in WAIVER_POINTERS { + assert_valid( + &validator, + &posture_with_waiver_field(waiver_pointer, "summary", json!(summary)), + ); + } + } +} + #[test] fn posture_schema_rejects_unknown_profile_or_severity_and_missing_waiver_expiry() { let validator = posture_validator(); @@ -143,7 +330,8 @@ fn posture_profile_gate_structs_round_trip_and_accept_unknown_finding_ids() { severity: GateSeverity::FindingWarn, status: DeploymentFindingStatus::Waived, waiver: Some(DeploymentFindingWaiver { - reason: "operator approved temporary exception".to_string(), + reference: "OPS-2026-0042".to_string(), + summary: Some("Operator approved temporary exception".to_string()), expires: "2026-07-01".to_string(), }), }, @@ -152,7 +340,8 @@ fn posture_profile_gate_structs_round_trip_and_accept_unknown_finding_ids() { "severity": "finding_warn", "status": "waived", "waiver": { - "reason": "operator approved temporary exception", + "reference": "OPS-2026-0042", + "summary": "Operator approved temporary exception", "expires": "2026-07-01" } }), @@ -161,17 +350,92 @@ fn posture_profile_gate_structs_round_trip_and_accept_unknown_finding_ids() { round_trip( DeploymentWaiver { finding: "future.product.finding".to_string(), - reason: "operator approved temporary exception".to_string(), + reference: "OPS-2026-0042".to_string(), + summary: None, expires: "2026-07-01".to_string(), }, json!({ "finding": "future.product.finding", - "reason": "operator approved temporary exception", + "reference": "OPS-2026-0042", "expires": "2026-07-01" }), ); } +#[test] +fn posture_waiver_structs_reject_explicit_null_summary() { + let finding_waiver = json!({ + "reference": "OPS-2026-0042", + "summary": null, + "expires": "2026-07-01" + }); + assert!( + serde_json::from_value::(finding_waiver).is_err(), + "finding waiver summary must be omitted rather than null" + ); + + let waiver = json!({ + "finding": "future.product.finding", + "reference": "OPS-2026-0042", + "summary": null, + "expires": "2026-07-01" + }); + assert!( + serde_json::from_value::(waiver).is_err(), + "deployment waiver summary must be omitted rather than null" + ); +} + +#[test] +fn posture_waiver_structs_reject_retired_or_invalid_metadata() { + for invalid in [ + json!({ + "finding": "future.product.finding", + "reference": "OPS-42", + "reason": "retired waiver text", + "expires": "2026-07-01" + }), + json!({ + "finding": "future.product.finding", + "reference": "OPS..42", + "expires": "2026-07-01" + }), + json!({ + "finding": "future.product.finding", + "reference": "OPS-42", + "summary": "bearer credential-value", + "expires": "2026-07-01" + }), + ] { + assert!( + serde_json::from_value::(invalid).is_err(), + "shared deployment waiver deserialization must enforce the closed metadata contract" + ); + } + + for invalid in [ + json!({ + "reference": "OPS-42", + "reason": "retired waiver text", + "expires": "2026-07-01" + }), + json!({ + "reference": "OPS..42", + "expires": "2026-07-01" + }), + json!({ + "reference": "OPS-42", + "summary": "summary\nwith control", + "expires": "2026-07-01" + }), + ] { + assert!( + serde_json::from_value::(invalid).is_err(), + "shared finding waiver deserialization must enforce the closed metadata contract" + ); + } +} + #[test] fn audit_assurance_types_round_trip() { round_trip( @@ -224,7 +488,7 @@ fn audit_assurance_types_round_trip() { } #[test] -fn default_filter_drops_waiver_reasons_from_profile_gate_fields() { +fn default_filter_drops_waiver_metadata_from_profile_gate_fields() { let filtered = registry_platform_ops::filter_posture_for_tier( posture_with_profile_gates(), registry_platform_ops::PostureTier::Default, @@ -244,5 +508,6 @@ fn default_filter_drops_waiver_reasons_from_profile_gate_fields() { .expect("deployment is object") .get("waivers") .is_none()); - assert!(!rendered.contains("temporary maintenance window")); + assert!(!rendered.contains("OPS-2026-0042")); + assert!(!rendered.contains("Temporary maintenance window")); } diff --git a/crates/registry-relay/CHANGELOG.md b/crates/registry-relay/CHANGELOG.md index 1f4172efd..b5a6f7815 100644 --- a/crates/registry-relay/CHANGELOG.md +++ b/crates/registry-relay/CHANGELOG.md @@ -2,6 +2,26 @@ ## Unreleased +- BREAKING: Remove the inert six-field `provenance.consent` member from the + pre-1.0 `registry.relay.consultation-result.v1` response and its closed + batch-terminal replay representation. The PostgreSQL terminal constraint is + replaced with the reduced exact shape, so earlier terminal payloads are not + accepted. Consent verification, policy enforcement, legal-basis handling, + evidence commitments, scopes, and trust headers are unchanged. A Relay + consultation state plane initialized with the earlier pre-release constraint + must be cleanly re-bootstrapped before running the updated binary. This is not + a rolling-compatible change: stop new consultations and let the old binary + serve retained terminal replays for their 15-minute retention window before + re-bootstrapping, or re-bootstrap immediately if discarding those pre-1.0 + terminal results is acceptable. Do not run old and updated binaries against + one state plane. +- BREAKING: Replace deployment-waiver `reason` with a required validated + `reference` and an optional validated `summary`. Strict configuration parsing + rejects the retired field. Restricted posture and boot warnings expose only + the new metadata, default posture continues to omit waiver metadata, and + waived-gate audit records remain minimized to finding IDs. Expiry and + non-waivable startup/readiness gate behavior are unchanged. + ## 0.12.2 - 2026-07-20 ### Security diff --git a/crates/registry-relay/docs/api.md b/crates/registry-relay/docs/api.md index faa95c509..a69185d55 100644 --- a/crates/registry-relay/docs/api.md +++ b/crates/registry-relay/docs/api.md @@ -153,7 +153,7 @@ A successful response has the closed `registry.relay.consultation-result.v1` env only `match`, `no_match`, or `ambiguous`. A `match` includes every declared typed output. `no_match` and `ambiguous` omit `outputs`. Every outcome includes the generated consultation id, required Notary evaluation id, exact profile id and `contract_hash`, acquisition time and class, -integration id and revision, snapshot evidence when applicable, and the closed consent result. +integration id and revision, and snapshot evidence when applicable. Raw inputs, source credentials, source URLs, static policy digests, and source diagnostics are never returned. diff --git a/crates/registry-relay/docs/configuration.md b/crates/registry-relay/docs/configuration.md index eaf0ffcee..7487d10b4 100644 --- a/crates/registry-relay/docs/configuration.md +++ b/crates/registry-relay/docs/configuration.md @@ -536,7 +536,8 @@ deployment.waivers deployment.waivers[] deployment.waivers[].expires deployment.waivers[].finding -deployment.waivers[].reason +deployment.waivers[].reference +deployment.waivers[].summary instance instance.environment instance.id @@ -1274,7 +1275,8 @@ deployment: audit_ack_max_age_secs: 900 # how old acked_at may get before the cursor reads as stale waivers: - finding: relay.openapi.public - reason: public API catalog is intentional for this deployment + reference: OPS-2026-0042 + summary: Public API catalog is intentional for this deployment expires: 2026-12-31 ``` @@ -1298,20 +1300,38 @@ Some controls live outside the relay and cannot be observed by the process (for ### Waivers -A triggered, waivable finding can be suppressed by a waiver that names the finding id, carries a free-text reason, and a mandatory expiry date (`YYYY-MM-DD`): +A triggered, waivable finding can be suppressed by a waiver that names the finding id, carries a required operator reference, and sets a mandatory expiry date (`YYYY-MM-DD`). An optional summary can add short operational context: ```yaml deployment: profile: hosted_lab waivers: - finding: relay.ingress.rate_limit_missing - reason: rate limiting is handled by the lab gateway + reference: OPS-2026-0042 + summary: Rate limiting is handled by the lab gateway expires: 2026-09-30 ``` -A waived finding reports status `waived` instead of its severity effect. Once the expiry date passes, the waiver stops suppressing the finding and the posture additionally raises `deployment.waiver_expired`. The expiry date is mandatory; reasons must be non-empty and must not contain secrets. A waiver naming a hard gate (`startup_fail` or `readiness_fail` severity under the active profile) fails config load instead of being silently accepted and ignored: there is no config-level override for a non-waivable gate. - -Waiver reasons are only visible in the restricted posture tier; the default tier reports finding id, severity, and status but not the reason. +A waived finding reports status `waived` instead of its severity effect. +Once the expiry date passes, the waiver stops suppressing the finding and the posture additionally +raises `deployment.waiver_expired`. +The `reference` is 1 to 128 bytes, has no surrounding whitespace, uses only letters, digits, `.`, +`_`, `:`, and `-`, and cannot contain `..`. +References cannot start, case-insensitively, with `Bearer:` or `Basic:`, directly or +after `Authorization:`. +Use a ticket-style reference such as `OPS-2026-0042`. +The optional `summary` is 1 to 256 Unicode characters when present, is already trimmed, contains +no control characters, and cannot be an authorization value or contain a private-key begin +marker. +Omit `summary` when it is not needed; explicit `null` is invalid. +Keep credentials and private keys out of both fields. +These rules implement +[RS-OP-POSTURE](https://docs.registrystack.org/spec/rs-op-posture/) (REQ-OP-POSTURE-011). +A waiver naming a hard gate (`startup_fail` or `readiness_fail` severity under the active profile) +fails config load instead of being silently accepted and ignored: there is no config-level +override for a non-waivable gate. + +Waiver references and summaries are visible only in the restricted posture tier; the default tier reports finding id, severity, and status without the per-finding waiver object or deployment waivers array. ### Findings catalog @@ -1337,7 +1357,7 @@ The current deployment profile, its findings, and active waivers are reported un ### Boot-time visibility -Reduced posture is loud at boot, not only visible on the posture surface. Every config load warns once per waiver-suppressed finding (`deployment.gate_waived`, with the finding id, reason, and expiry), once per expired waiver (`deployment.waiver_expired`), and once when the profile is undeclared (`deployment.profile_undeclared`). The serve path additionally writes one operational audit record per waived gate at boot, once the audit pipeline exists: event `deployment.gate_waived` at audit path `/__events/deployment.gate_waived`, with `error_code` set to the gate id. +Reduced posture is loud at boot, not only visible on the posture surface. Every config load warns once per waiver-suppressed finding (`deployment.gate_waived`, with the finding id, reference, optional summary, and expiry), once per expired waiver (`deployment.waiver_expired`), and once when the profile is undeclared (`deployment.profile_undeclared`). The serve path additionally writes one operational audit record per waived gate at boot, once the audit pipeline exists: event `deployment.gate_waived` at audit path `/__events/deployment.gate_waived`, with `error_code` set to the gate id. That minimized audit record does not copy waiver metadata. This boot-time audit write inherits `audit.write_policy` (see below). Under `fail_closed` (the default), a failed write aborts startup. Under `availability_first`, the failure is logged (`audit.operational_event_write_failed`) and startup continues, so the durable record is best-effort; the per-gate boot log warnings above remain the guaranteed floor. diff --git a/crates/registry-relay/openapi/oasdiff-err-ignore.txt b/crates/registry-relay/openapi/oasdiff-err-ignore.txt index b94ab98ef..ee56082af 100644 --- a/crates/registry-relay/openapi/oasdiff-err-ignore.txt +++ b/crates/registry-relay/openapi/oasdiff-err-ignore.txt @@ -21,3 +21,7 @@ GET /v1/datasets/{dataset_id}/entities/{entity}/schema added the new path reques GET /v1/datasets/{dataset_id}/entities/{entity}/schema added the new path request parameter 'entity' GET /v1/datasets/{dataset_id}/measures added the new path request parameter 'dataset_id' GET /v1/datasets/{dataset_id}/measures/{item_id} added the new path request parameter 'dataset_id' + +# One-time accepted diff for issue #362 on 2026-07-20: remove the inert +# provenance.consent member from the pre-1.0 consultation result. +POST /v1/consultations/{profile_id}/execute added `subschema #1, subschema #2, subschema #3` to the response body `oneOf` list for the response status `200` diff --git a/crates/registry-relay/openapi/registry-relay.openapi.json b/crates/registry-relay/openapi/registry-relay.openapi.json index 570a2211e..8613a5401 100644 --- a/crates/registry-relay/openapi/registry-relay.openapi.json +++ b/crates/registry-relay/openapi/registry-relay.openapi.json @@ -1298,54 +1298,6 @@ "bounded_full_record" ] }, - "consent": { - "additionalProperties": false, - "properties": { - "checked_at": { - "format": "date-time", - "type": [ - "string", - "null" - ] - }, - "expires_at": { - "format": "date-time", - "type": [ - "string", - "null" - ] - }, - "outcome": { - "const": "not_required" - }, - "revocation_status": { - "const": "not_applicable" - }, - "verifier_id": { - "maxLength": 128, - "type": [ - "string", - "null" - ] - }, - "verifier_revision": { - "maxLength": 128, - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "outcome", - "verifier_id", - "verifier_revision", - "checked_at", - "expires_at", - "revocation_status" - ], - "type": "object" - }, "integration": { "additionalProperties": false, "properties": { @@ -1384,8 +1336,7 @@ "source_observed_at", "source_revision", "acquisition_class", - "integration", - "consent" + "integration" ], "type": "object" }, @@ -1399,54 +1350,6 @@ "acquisition_class": { "const": "materialized_snapshot" }, - "consent": { - "additionalProperties": false, - "properties": { - "checked_at": { - "format": "date-time", - "type": [ - "string", - "null" - ] - }, - "expires_at": { - "format": "date-time", - "type": [ - "string", - "null" - ] - }, - "outcome": { - "const": "not_required" - }, - "revocation_status": { - "const": "not_applicable" - }, - "verifier_id": { - "maxLength": 128, - "type": [ - "string", - "null" - ] - }, - "verifier_revision": { - "maxLength": 128, - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "outcome", - "verifier_id", - "verifier_revision", - "checked_at", - "expires_at", - "revocation_status" - ], - "type": "object" - }, "integration": { "additionalProperties": false, "properties": { @@ -1504,7 +1407,6 @@ "source_revision", "acquisition_class", "integration", - "consent", "snapshot" ], "type": "object" @@ -1573,54 +1475,6 @@ "bounded_full_record" ] }, - "consent": { - "additionalProperties": false, - "properties": { - "checked_at": { - "format": "date-time", - "type": [ - "string", - "null" - ] - }, - "expires_at": { - "format": "date-time", - "type": [ - "string", - "null" - ] - }, - "outcome": { - "const": "not_required" - }, - "revocation_status": { - "const": "not_applicable" - }, - "verifier_id": { - "maxLength": 128, - "type": [ - "string", - "null" - ] - }, - "verifier_revision": { - "maxLength": 128, - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "outcome", - "verifier_id", - "verifier_revision", - "checked_at", - "expires_at", - "revocation_status" - ], - "type": "object" - }, "integration": { "additionalProperties": false, "properties": { @@ -1659,8 +1513,7 @@ "source_observed_at", "source_revision", "acquisition_class", - "integration", - "consent" + "integration" ], "type": "object" }, @@ -1674,54 +1527,6 @@ "acquisition_class": { "const": "materialized_snapshot" }, - "consent": { - "additionalProperties": false, - "properties": { - "checked_at": { - "format": "date-time", - "type": [ - "string", - "null" - ] - }, - "expires_at": { - "format": "date-time", - "type": [ - "string", - "null" - ] - }, - "outcome": { - "const": "not_required" - }, - "revocation_status": { - "const": "not_applicable" - }, - "verifier_id": { - "maxLength": 128, - "type": [ - "string", - "null" - ] - }, - "verifier_revision": { - "maxLength": 128, - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "outcome", - "verifier_id", - "verifier_revision", - "checked_at", - "expires_at", - "revocation_status" - ], - "type": "object" - }, "integration": { "additionalProperties": false, "properties": { @@ -1779,7 +1584,6 @@ "source_revision", "acquisition_class", "integration", - "consent", "snapshot" ], "type": "object" @@ -1847,54 +1651,6 @@ "bounded_full_record" ] }, - "consent": { - "additionalProperties": false, - "properties": { - "checked_at": { - "format": "date-time", - "type": [ - "string", - "null" - ] - }, - "expires_at": { - "format": "date-time", - "type": [ - "string", - "null" - ] - }, - "outcome": { - "const": "not_required" - }, - "revocation_status": { - "const": "not_applicable" - }, - "verifier_id": { - "maxLength": 128, - "type": [ - "string", - "null" - ] - }, - "verifier_revision": { - "maxLength": 128, - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "outcome", - "verifier_id", - "verifier_revision", - "checked_at", - "expires_at", - "revocation_status" - ], - "type": "object" - }, "integration": { "additionalProperties": false, "properties": { @@ -1933,8 +1689,7 @@ "source_observed_at", "source_revision", "acquisition_class", - "integration", - "consent" + "integration" ], "type": "object" }, @@ -1948,54 +1703,6 @@ "acquisition_class": { "const": "materialized_snapshot" }, - "consent": { - "additionalProperties": false, - "properties": { - "checked_at": { - "format": "date-time", - "type": [ - "string", - "null" - ] - }, - "expires_at": { - "format": "date-time", - "type": [ - "string", - "null" - ] - }, - "outcome": { - "const": "not_required" - }, - "revocation_status": { - "const": "not_applicable" - }, - "verifier_id": { - "maxLength": 128, - "type": [ - "string", - "null" - ] - }, - "verifier_revision": { - "maxLength": 128, - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "outcome", - "verifier_id", - "verifier_revision", - "checked_at", - "expires_at", - "revocation_status" - ], - "type": "object" - }, "integration": { "additionalProperties": false, "properties": { @@ -2053,7 +1760,6 @@ "source_revision", "acquisition_class", "integration", - "consent", "snapshot" ], "type": "object" diff --git a/crates/registry-relay/src/api/admin.rs b/crates/registry-relay/src/api/admin.rs index b89b627d2..fdd05c0da 100644 --- a/crates/registry-relay/src/api/admin.rs +++ b/crates/registry-relay/src/api/admin.rs @@ -578,7 +578,7 @@ fn audit_shipping_state(config: &Config) -> (bool, &'static str) { /// Build the `deployment` posture object: declared profile, gate findings, and /// active waivers. Findings carry only `{id, severity, status}` plus an /// optional waiver block; the default-tier posture filter strips waiver -/// reasons. `findings` and `waivers` are always present (possibly empty). +/// metadata. `findings` and `waivers` are always present (possibly empty). fn deployment_summary_with_observation( config: &Config, config_source: ConfigSource, @@ -613,11 +613,7 @@ fn deployment_summary_with_observation( json!(evaluation .active_waivers .iter() - .map(|waiver| json!({ - "finding": waiver.finding, - "reason": waiver.reason, - "expires": waiver.expires, - })) + .map(deployment_waiver_json) .collect::>()), ); (Value::Object(summary), !evaluation.has_readiness_failure()) @@ -653,14 +649,31 @@ fn deployment_finding_json(finding: ®istry_platform_ops::DeploymentFinding) - object.insert("severity".to_string(), json!(finding.severity.as_str())); object.insert("status".to_string(), json!(finding.status.as_str())); if let Some(waiver) = &finding.waiver { - object.insert( - "waiver".to_string(), - json!({ - "reason": waiver.reason, - "expires": waiver.expires, - }), - ); + object.insert("waiver".to_string(), deployment_finding_waiver_json(waiver)); + } + Value::Object(object) +} + +fn deployment_finding_waiver_json( + waiver: ®istry_platform_ops::DeploymentFindingWaiver, +) -> Value { + let mut object = Map::new(); + object.insert("reference".to_string(), json!(waiver.reference)); + if let Some(summary) = &waiver.summary { + object.insert("summary".to_string(), json!(summary)); } + object.insert("expires".to_string(), json!(waiver.expires)); + Value::Object(object) +} + +fn deployment_waiver_json(waiver: ®istry_platform_ops::DeploymentWaiver) -> Value { + let mut object = Map::new(); + object.insert("finding".to_string(), json!(waiver.finding)); + object.insert("reference".to_string(), json!(waiver.reference)); + if let Some(summary) = &waiver.summary { + object.insert("summary".to_string(), json!(summary)); + } + object.insert("expires".to_string(), json!(waiver.expires)); Value::Object(object) } @@ -1176,18 +1189,18 @@ datasets: [] } /// The default-tier allowlist exposes only finding id/severity/status; the - /// whole deployment `waivers` block (finding, reason, expires) is dropped. - /// The restricted tier returns the unfiltered document, so the waiver and - /// its reason appear there. This pins the allowlist contract for the new - /// deployment block, including that synthetic waiver reasons never leak at - /// the default tier. + /// whole deployment `waivers` block is dropped. The restricted tier + /// returns the unfiltered document, so the waiver reference and optional + /// summary appear there. This pins the allowlist contract, including that + /// synthetic waiver metadata never leaks at the default tier. #[test] fn posture_default_tier_drops_waivers_restricted_keeps_them() { let mut config = parse_minimal_config(&minimal_config_yaml()); config.deployment.profile = Some(DeploymentProfile::HostedLab); config.deployment.waivers = vec![crate::config::DeploymentWaiverConfig { finding: "relay.config.unsigned".to_string(), - reason: "synthetic-waiver-reason-not-a-secret".to_string(), + reference: "OPS-TEST-1188".to_string(), + summary: Some("Synthetic waiver summary".to_string()), expires: "2999-01-01".to_string(), }]; @@ -1211,11 +1224,15 @@ datasets: [] ); let serialized = default_tier.to_string(); assert!( - !serialized.contains("synthetic-waiver-reason-not-a-secret"), - "default-tier posture must never leak a waiver reason" + !serialized.contains("OPS-TEST-1188"), + "default-tier posture must never leak a waiver reference" + ); + assert!( + !serialized.contains("Synthetic waiver summary"), + "default-tier posture must never leak a waiver summary" ); - // Restricted tier: full document, waiver and reason present. + // Restricted tier: full document and validated waiver metadata present. let restricted = build_posture( &config, None, @@ -1230,10 +1247,8 @@ datasets: [] assert_eq!(restricted_waivers.len(), 1); assert_eq!(restricted_waivers[0]["finding"], "relay.config.unsigned"); assert_eq!(restricted_waivers[0]["expires"], "2999-01-01"); - assert_eq!( - restricted_waivers[0]["reason"], - "synthetic-waiver-reason-not-a-secret" - ); + assert_eq!(restricted_waivers[0]["reference"], "OPS-TEST-1188"); + assert_eq!(restricted_waivers[0]["summary"], "Synthetic waiver summary"); } /// Default-config posture regression: the gate train adds exactly the diff --git a/crates/registry-relay/src/api/openapi.rs b/crates/registry-relay/src/api/openapi.rs index 2ff311489..04332cf0b 100644 --- a/crates/registry-relay/src/api/openapi.rs +++ b/crates/registry-relay/src/api/openapi.rs @@ -3127,7 +3127,6 @@ fn consultation_provenance_schema() -> Value { "source_revision", "acquisition_class", "integration", - "consent", ]; let common_properties = json!({ "acquired_at": { "type": "string", "format": "date-time" }, @@ -3141,19 +3140,6 @@ fn consultation_provenance_schema() -> Value { "revision": { "type": "integer", "minimum": 1 } }, "additionalProperties": false - }, - "consent": { - "type": "object", - "required": ["outcome", "verifier_id", "verifier_revision", "checked_at", "expires_at", "revocation_status"], - "properties": { - "outcome": { "const": "not_required" }, - "verifier_id": { "type": ["string", "null"], "maxLength": 128 }, - "verifier_revision": { "type": ["string", "null"], "maxLength": 128 }, - "checked_at": { "type": ["string", "null"], "format": "date-time" }, - "expires_at": { "type": ["string", "null"], "format": "date-time" }, - "revocation_status": { "const": "not_applicable" } - }, - "additionalProperties": false } }); let mut live_properties = common_properties.as_object().cloned().expect("object"); @@ -6091,6 +6077,16 @@ mod tests { .len(), 2 ); + for provenance in variant["properties"]["provenance"]["oneOf"] + .as_array() + .unwrap() + { + assert!(provenance["properties"]["consent"].is_null()); + assert!(!provenance["required"] + .as_array() + .expect("required provenance fields") + .contains(&json!("consent"))); + } } assert_eq!( variants[0]["properties"]["outputs"]["additionalProperties"]["type"], diff --git a/crates/registry-relay/src/config/mod.rs b/crates/registry-relay/src/config/mod.rs index 11d7d5c7f..ed94f11d9 100644 --- a/crates/registry-relay/src/config/mod.rs +++ b/crates/registry-relay/src/config/mod.rs @@ -103,9 +103,10 @@ pub struct DeploymentConfig { #[serde(default)] #[schemars(with = "Option")] pub profile: Option, - /// Per-deployment waivers. Each names one finding id, a free-text reason, - /// and a mandatory expiry date. Expired waivers stop suppressing their - /// finding and raise `deployment.waiver_expired`. + /// Per-deployment waivers. Each names one finding id, a required operator + /// reference, an optional summary, and a mandatory expiry date. Expired + /// waivers stop suppressing their finding and raise + /// `deployment.waiver_expired`. #[serde(default)] pub waivers: Vec, /// Operator declarations of assurance evidence the runtime cannot observe @@ -116,15 +117,42 @@ pub struct DeploymentConfig { } /// One declared waiver. `expires` is an ISO 8601 `YYYY-MM-DD` date; format is -/// validated at load time. Reasons must not carry secrets. +/// validated at load time. The reference and optional summary are validated by +/// the shared operations contract before either can reach posture or logs. #[derive(Debug, Clone, Deserialize, JsonSchema, PartialEq)] -#[serde(deny_unknown_fields)] +#[serde(deny_unknown_fields, from = "DeploymentWaiverConfigDocument")] +#[schemars(!from)] pub struct DeploymentWaiverConfig { pub finding: String, - pub reason: String, + #[schemars(with = "schema::DeploymentWaiverReferenceSchema")] + pub reference: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(with = "schema::DeploymentWaiverSummarySchema")] + pub summary: Option, pub expires: String, } +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct DeploymentWaiverConfigDocument { + finding: String, + reference: String, + #[serde(default)] + summary: registry_platform_ops::OptionalDeploymentWaiverSummary, + expires: String, +} + +impl From for DeploymentWaiverConfig { + fn from(value: DeploymentWaiverConfigDocument) -> Self { + Self { + finding: value.finding, + reference: value.reference, + summary: value.summary.into(), + expires: value.expires, + } + } +} + /// Operator-asserted assurance evidence for conditions the runtime cannot /// observe directly. Each flag defaults to `false`, meaning "no evidence /// declared", which keeps the corresponding gate active until the operator diff --git a/crates/registry-relay/src/config/schema.rs b/crates/registry-relay/src/config/schema.rs index f4ae91637..848a430c9 100644 --- a/crates/registry-relay/src/config/schema.rs +++ b/crates/registry-relay/src/config/schema.rs @@ -21,6 +21,36 @@ use super::Config; pub const CONFIG_SCHEMA_ID: &str = "https://id.registrystack.org/schemas/registry-relay/registry-relay.config.schema.json"; +/// Schema-only deployment-waiver reference contract shared with posture. +pub(crate) struct DeploymentWaiverReferenceSchema; + +impl JsonSchema for DeploymentWaiverReferenceSchema { + fn schema_name() -> Cow<'static, str> { + "DeploymentWaiverReference".into() + } + + fn json_schema(_: &mut SchemaGenerator) -> Schema { + registry_platform_ops::deployment_waiver_reference_schema_fragment() + .try_into() + .expect("the shared waiver-reference fragment is a valid JSON Schema") + } +} + +/// Schema-only structural deployment-waiver summary contract shared with posture. +pub(crate) struct DeploymentWaiverSummarySchema; + +impl JsonSchema for DeploymentWaiverSummarySchema { + fn schema_name() -> Cow<'static, str> { + "DeploymentWaiverSummary".into() + } + + fn json_schema(_: &mut SchemaGenerator) -> Schema { + registry_platform_ops::deployment_waiver_summary_schema_fragment() + .try_into() + .expect("the shared waiver-summary fragment is a valid JSON Schema") + } +} + const IPV4_SOCKET_PATTERN: &str = concat!( "^(?:", r"(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\.", diff --git a/crates/registry-relay/src/config/validate.rs b/crates/registry-relay/src/config/validate.rs index cae243b18..233272570 100644 --- a/crates/registry-relay/src/config/validate.rs +++ b/crates/registry-relay/src/config/validate.rs @@ -383,10 +383,11 @@ fn validate_audit_ack_cursor(config: &Config) -> Result<(), ConfigError> { /// Validate the deployment block and evaluate startup gates. /// /// Waiver finding ids must match the finding id pattern, waiver dates must be -/// well-formed `YYYY-MM-DD`, reasons must be non-empty, and the deployment must -/// not omit `deployment.profile` or declare a profile under which any unwaived -/// `startup_fail` gate triggers. An invalid profile value is rejected earlier by -/// `serde` (the parse error path); this check covers the conditions that only +/// well-formed `YYYY-MM-DD`, and waiver references and optional summaries must +/// pass the shared operations metadata contract. The deployment must not omit +/// `deployment.profile` or declare a profile under which any unwaived +/// `startup_fail` gate triggers. An invalid profile value is rejected earlier +/// by `serde` (the parse error path); this check covers the conditions that only /// hold once the whole config is deserialised. /// /// `source` is the config's real provenance source. The startup path passes @@ -394,7 +395,7 @@ fn validate_audit_ack_cursor(config: &Config) -> Result<(), ConfigError> { /// candidate's real source so a signed bundle is evaluated as such and the /// `relay.config.unsigned` gate does not fire for it. fn validate_deployment(config: &Config, source: ConfigSource) -> Result<(), ConfigError> { - for waiver in &config.deployment.waivers { + for (index, waiver) in config.deployment.waivers.iter().enumerate() { if waiver.finding.trim().is_empty() { tracing::error!( code = "config.validation_error", @@ -410,11 +411,16 @@ fn validate_deployment(config: &Config, source: ConfigSource) -> Result<(), Conf ); return Err(ConfigError::ValidationError); } - if waiver.reason.trim().is_empty() { + if let Err(error) = registry_platform_ops::validate_deployment_waiver_metadata( + &waiver.reference, + waiver.summary.as_deref(), + ) { tracing::error!( code = "config.validation_error", finding = %waiver.finding, - "deployment waiver is missing a reason" + field = %format!("deployment.waivers[{index}].{}", error.field()), + error = %error, + "deployment waiver metadata is invalid" ); return Err(ConfigError::ValidationError); } @@ -482,27 +488,35 @@ fn validate_deployment(config: &Config, source: ConfigSource) -> Result<(), Conf /// profile each get a warn line, so a boot that runs with reduced posture is /// loud in the log instead of visible only on the posture surface. fn log_deployment_boot_findings(evaluation: &crate::deployment::GateEvaluation) { - fn waiver_fields(finding: ®istry_platform_ops::DeploymentFinding) -> (&str, &str) { - finding.waiver.as_ref().map_or(("", ""), |waiver| { - (waiver.reason.as_str(), waiver.expires.as_str()) + fn waiver_fields( + finding: ®istry_platform_ops::DeploymentFinding, + ) -> (&str, Option<&str>, &str) { + finding.waiver.as_ref().map_or(("", None, ""), |waiver| { + ( + waiver.reference.as_str(), + waiver.summary.as_deref(), + waiver.expires.as_str(), + ) }) } for finding in &evaluation.findings { if finding.status == registry_platform_ops::DeploymentFindingStatus::Waived { - let (reason, expires) = waiver_fields(finding); + let (reference, summary, expires) = waiver_fields(finding); tracing::warn!( code = "deployment.gate_waived", finding = %finding.id, - reason = %reason, + reference = %reference, + summary = ?summary, expires = %expires, "deployment gate finding is suppressed by an active waiver" ); } else if finding.id == crate::deployment::WAIVER_EXPIRED { - let (reason, expires) = waiver_fields(finding); + let (reference, summary, expires) = waiver_fields(finding); tracing::warn!( code = "deployment.waiver_expired", - reason = %reason, + reference = %reference, + summary = ?summary, expires = %expires, "deployment waiver is expired; its gate binds again" ); @@ -5092,7 +5106,7 @@ deployment: #[test] fn active_waiver_is_loud_in_the_boot_log() { let config = parse_deployment_config( - "deployment:\n profile: hosted_lab\n waivers:\n - finding: relay.config.unsigned\n reason: \"synthetic-waiver-not-a-secret\"\n expires: \"2999-01-01\"", + "deployment:\n profile: hosted_lab\n waivers:\n - finding: relay.config.unsigned\n reference: OPS-TEST-BOOT\n summary: \"Synthetic waiver summary\"\n expires: \"2999-01-01\"", ); let (result, rendered) = run_with_captured_logs(&config); result.expect("a waived hosted_lab gate must pass validation"); @@ -5105,8 +5119,12 @@ deployment: "expected the waived finding id in boot log: {rendered}" ); assert!( - rendered.contains("synthetic-waiver-not-a-secret"), - "expected the waiver reason in boot log: {rendered}" + rendered.contains("OPS-TEST-BOOT"), + "expected the waiver reference in boot log: {rendered}" + ); + assert!( + rendered.contains("Synthetic waiver summary"), + "expected the waiver summary in boot log: {rendered}" ); assert!( rendered.contains("2999-01-01"), @@ -5117,7 +5135,7 @@ deployment: #[test] fn expired_waiver_is_loud_in_the_boot_log() { let config = parse_deployment_config( - "deployment:\n profile: hosted_lab\n waivers:\n - finding: relay.config.unsigned\n reason: \"synthetic-waiver-not-a-secret\"\n expires: \"2000-01-01\"", + "deployment:\n profile: hosted_lab\n waivers:\n - finding: relay.config.unsigned\n reference: OPS-TEST-EXPIRED\n expires: \"2000-01-01\"", ); let (result, rendered) = run_with_captured_logs(&config); result.expect("an expired hosted_lab waiver must still pass validation"); @@ -5206,7 +5224,7 @@ deployment: // A malformed finding id is rejected at config load, before it could // surface later as restricted-tier posture schema invalidity. let config = parse_deployment_config( - "deployment:\n profile: hosted_lab\n waivers:\n - finding: \"Relay.Config.Unsigned\"\n reason: \"synthetic-waiver-not-a-secret\"\n expires: \"2999-01-01\"", + "deployment:\n profile: hosted_lab\n waivers:\n - finding: \"Relay.Config.Unsigned\"\n reference: OPS-TEST-BAD-ID\n expires: \"2999-01-01\"", ); assert!( run(&config).is_err(), @@ -5217,7 +5235,7 @@ deployment: #[test] fn waiver_finding_id_accepts_canonical_dotted_id() { let config = parse_deployment_config( - "deployment:\n profile: hosted_lab\n waivers:\n - finding: \"relay.config.unsigned\"\n reason: \"synthetic-waiver-not-a-secret\"\n expires: \"2999-01-01\"", + "deployment:\n profile: hosted_lab\n waivers:\n - finding: \"relay.config.unsigned\"\n reference: OPS-TEST-VALID-ID\n expires: \"2999-01-01\"", ); run(&config).expect("a canonical dotted finding id must pass validation"); } @@ -5228,10 +5246,10 @@ deployment: // cannot be waived. The waiver must be rejected at load, not silently // dropped. Evaluate as a signed bundle so the `relay.config.unsigned` // startup gate does not fire: the hard-gate waiver is then the only - // reason validation can fail (the base config uses a stdout sink, so + // validation failure (the base config uses a stdout sink, so // the retention condition itself does not even trigger here). let config = parse_deployment_config( - "deployment:\n profile: evidence_grade\n waivers:\n - finding: relay.audit.retention_local_only\n reason: \"synthetic-waiver-not-a-secret\"\n expires: \"2999-01-01\"", + "deployment:\n profile: evidence_grade\n waivers:\n - finding: relay.audit.retention_local_only\n reference: OPS-TEST-HARD-RETENTION\n expires: \"2999-01-01\"", ); let (result, rendered) = capture_logs(|| run_with_source(&config, ConfigSource::SignedBundleFile)); @@ -5277,7 +5295,7 @@ deployment: profile: production waivers: - finding: relay.audit.retention_local_only - reason: "synthetic-waiver-not-a-secret" + reference: OPS-TEST-WAIVABLE-RETENTION expires: "2999-01-01" "#, ) @@ -5306,9 +5324,9 @@ deployment: // validation error, not silently dropped, even though the condition // itself does not hold here. Evaluate as a signed bundle so the // `relay.config.unsigned` startup gate does not fire and the readiness - // waiver is the only reason validation can fail. + // waiver is the only validation failure. let config = parse_deployment_config( - "deployment:\n profile: evidence_grade\n waivers:\n - finding: relay.audit.best_effort\n reason: \"synthetic-waiver-not-a-secret\"\n expires: \"2999-01-01\"", + "deployment:\n profile: evidence_grade\n waivers:\n - finding: relay.audit.best_effort\n reference: OPS-TEST-HARD-BEST-EFFORT\n expires: \"2999-01-01\"", ); let (result, rendered) = capture_logs(|| run_with_source(&config, ConfigSource::SignedBundleFile)); @@ -5436,7 +5454,7 @@ deployment: // does not hold. Evaluate as a signed bundle so the // `relay.config.unsigned` startup gate does not fire. let config = parse_deployment_config( - "deployment:\n profile: evidence_grade\n waivers:\n - finding: relay.audit.shipping_stale\n reason: \"synthetic-waiver-not-a-secret\"\n expires: \"2999-01-01\"", + "deployment:\n profile: evidence_grade\n waivers:\n - finding: relay.audit.shipping_stale\n reference: OPS-TEST-HARD-SHIPPING\n expires: \"2999-01-01\"", ); let (result, rendered) = capture_logs(|| run_with_source(&config, ConfigSource::SignedBundleFile)); diff --git a/crates/registry-relay/src/consultation/response.rs b/crates/registry-relay/src/consultation/response.rs index 9f24866e2..a9e825508 100644 --- a/crates/registry-relay/src/consultation/response.rs +++ b/crates/registry-relay/src/consultation/response.rs @@ -96,7 +96,6 @@ struct BatchTerminalProvenance { integration: BatchTerminalIntegration, #[serde(skip_serializing_if = "Option::is_none")] snapshot: Option, - consent: BatchTerminalConsent, } #[derive(Serialize, Deserialize)] @@ -113,17 +112,6 @@ struct BatchTerminalSnapshot { published_at: String, } -#[derive(Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct BatchTerminalConsent { - outcome: String, - verifier_id: Option, - verifier_revision: Option, - checked_at: Option, - expires_at: Option, - revocation_status: String, -} - enum BatchTerminalScalar { Null, String(Zeroizing), @@ -365,14 +353,6 @@ impl PublishableConsultationResponse { revision: integration_pack.version().get(), }, snapshot, - consent: BatchTerminalConsent { - outcome: "not_required".to_owned(), - verifier_id: None, - verifier_revision: None, - checked_at: None, - expires_at: None, - revocation_status: "not_applicable".to_owned(), - }, }, }; let configured_limit = @@ -514,12 +494,6 @@ impl BatchTerminalPayload { != acquisition_class_str(profile.footprint().acquisition_class()) || self.provenance.integration.id != profile.integration_pack().id().as_str() || self.provenance.integration.revision != profile.integration_pack().version().get() - || self.provenance.consent.outcome != "not_required" - || self.provenance.consent.revocation_status != "not_applicable" - || self.provenance.consent.verifier_id.is_some() - || self.provenance.consent.verifier_revision.is_some() - || self.provenance.consent.checked_at.is_some() - || self.provenance.consent.expires_at.is_some() || !valid_rfc3339_milliseconds(&self.provenance.acquired_at) { return Err(ConsultationResponseError::Serialization); @@ -656,14 +630,6 @@ impl BatchTerminalPayload { generation_id: &snapshot.generation_id, published_at: &snapshot.published_at, }), - consent: ResponseConsent { - outcome: &self.provenance.consent.outcome, - verifier_id: self.provenance.consent.verifier_id.as_deref(), - verifier_revision: self.provenance.consent.verifier_revision.as_deref(), - checked_at: self.provenance.consent.checked_at.as_deref(), - expires_at: self.provenance.consent.expires_at.as_deref(), - revocation_status: &self.provenance.consent.revocation_status, - }, }, }; let mut bytes = Zeroizing::new(Vec::with_capacity(limit)); @@ -715,7 +681,6 @@ struct ResponseProvenance<'a> { integration: ResponseIntegration<'a>, #[serde(skip_serializing_if = "Option::is_none")] snapshot: Option>, - consent: ResponseConsent<'a>, } #[derive(Serialize)] @@ -730,16 +695,6 @@ struct ResponseSnapshot<'a> { published_at: &'a str, } -#[derive(Serialize)] -struct ResponseConsent<'a> { - outcome: &'a str, - verifier_id: Option<&'a str>, - verifier_revision: Option<&'a str>, - checked_at: Option<&'a str>, - expires_at: Option<&'a str>, - revocation_status: &'a str, -} - enum ProjectedRecord<'a> { Outputs(&'a ValidatedOutputMap), Snapshot { @@ -1272,17 +1227,7 @@ mod tests { assert!(value["provenance"].get("snapshot").is_none()); assert!(value["provenance"].get("policy_id").is_none()); assert!(value["provenance"].get("policy_hash").is_none()); - assert_eq!( - value["provenance"]["consent"], - json!({ - "outcome": "not_required", - "verifier_id": null, - "verifier_revision": null, - "checked_at": null, - "expires_at": null, - "revocation_status": "not_applicable" - }) - ); + assert!(value["provenance"].get("consent").is_none()); assert_eq!( value.get("notary_evaluation_id").and_then(Value::as_str), Some(evaluation_id.to_canonical_string()).as_deref() @@ -1290,6 +1235,41 @@ mod tests { } } + #[test] + fn batch_terminal_replay_rejects_retired_consent_member() { + let plan = dhis2_runtime_vector_plan_fixture(); + let profile = plan.runtime_profile(); + let candidate = PublishableConsultationResponse::serialize_validated_live_result( + ConsultationId::generate(), + Some(NotaryEvaluationId::try_parse("01JYZZZZZZZZZZZZZZZZZZZZZZ").unwrap()), + profile, + ConsultationOutcome::NoMatch, + None, + 1_752_148_800_123, + ) + .expect("frozen response serializes"); + let mut terminal: Value = serde_json::from_str( + candidate + .batch_terminal_json() + .expect("batch terminal serializes") + .as_str(), + ) + .expect("batch terminal is JSON"); + terminal["provenance"]["consent"] = json!({ + "outcome": "not_required", + "verifier_id": null, + "verifier_revision": null, + "checked_at": null, + "expires_at": null, + "revocation_status": "not_applicable" + }); + + assert!(matches!( + BatchTerminalPayload::from_persisted(&terminal.to_string()), + Err(ConsultationResponseError::Serialization) + )); + } + #[test] fn public_result_requires_the_notary_evaluation_id() { let plan = dhis2_runtime_vector_plan_fixture(); diff --git a/crates/registry-relay/src/deployment/mod.rs b/crates/registry-relay/src/deployment/mod.rs index 31aa7660f..cc69faa40 100644 --- a/crates/registry-relay/src/deployment/mod.rs +++ b/crates/registry-relay/src/deployment/mod.rs @@ -16,7 +16,8 @@ //! * `finding_error` / `finding_warn`: a posture finding only. //! //! A triggered gate can be suppressed by a config waiver that names the -//! finding, carries a free-text reason, and a mandatory expiry date. A waived +//! finding, carries a required operator reference, and sets a mandatory expiry +//! date. An optional summary can add validated operational context. A waived //! finding reports status `waived` instead of its severity effect. An expired //! waiver stops suppressing the finding and additionally raises //! `deployment.waiver_expired`. `startup_fail` and `readiness_fail` gates are @@ -52,12 +53,14 @@ pub const PROFILE_UNDECLARED: &str = "deployment.profile_undeclared"; /// has passed its expiry date. pub const WAIVER_EXPIRED: &str = "deployment.waiver_expired"; -/// A waiver as declared in config: one finding id, a reason, and a mandatory -/// expiry date in `YYYY-MM-DD` form. +/// A waiver as declared in config: one finding id, a required operator +/// reference, an optional summary, and a mandatory expiry date in `YYYY-MM-DD` +/// form. #[derive(Clone, Debug, Eq, PartialEq)] pub struct WaiverInput { pub finding: String, - pub reason: String, + pub reference: String, + pub summary: Option, pub expires: String, } @@ -322,7 +325,8 @@ pub fn evaluate( severity, status: DeploymentFindingStatus::Waived, waiver: Some(DeploymentFindingWaiver { - reason: waiver.reason.clone(), + reference: waiver.reference.clone(), + summary: waiver.summary.clone(), expires: waiver.expires.clone(), }), }); @@ -358,14 +362,16 @@ pub fn evaluate( severity: FindingError, status: DeploymentFindingStatus::Active, waiver: Some(DeploymentFindingWaiver { - reason: waiver.reason.clone(), + reference: waiver.reference.clone(), + summary: waiver.summary.clone(), expires: waiver.expires.clone(), }), }); } else { evaluation.active_waivers.push(DeploymentWaiver { finding: waiver.finding.clone(), - reason: waiver.reason.clone(), + reference: waiver.reference.clone(), + summary: waiver.summary.clone(), expires: waiver.expires.clone(), }); } @@ -645,7 +651,8 @@ pub fn waivers_from_config(config: &Config) -> Vec { .iter() .map(|waiver| WaiverInput { finding: waiver.finding.clone(), - reason: waiver.reason.clone(), + reference: waiver.reference.clone(), + summary: waiver.summary.clone(), expires: waiver.expires.clone(), }) .collect() @@ -1118,7 +1125,8 @@ mod tests { let id = "relay.audit.retention_local_only"; let waivers = [WaiverInput { finding: id.to_string(), - reason: "synthetic test waiver".to_string(), + reference: "OPS-TEST-1125".to_string(), + summary: Some("Synthetic test waiver".to_string()), expires: FUTURE.to_string(), }]; let evaluation = evaluate(Some(DeploymentProfile::Production), &facts, &waivers, TODAY); @@ -1257,7 +1265,8 @@ mod tests { let id = "relay.audit.shipping_stale"; let waivers = [WaiverInput { finding: id.to_string(), - reason: "synthetic stale-shipping waiver".to_string(), + reference: "OPS-TEST-1264".to_string(), + summary: Some("Synthetic stale-shipping waiver".to_string()), expires: FUTURE.to_string(), }]; let evaluation = evaluate( @@ -1283,7 +1292,8 @@ mod tests { }; let waivers = [WaiverInput { finding: "relay.openapi.public".to_string(), - reason: "synthetic test waiver".to_string(), + reference: "OPS-TEST-1290".to_string(), + summary: Some("Synthetic test waiver".to_string()), expires: FUTURE.to_string(), }]; let evaluation = evaluate(Some(DeploymentProfile::Production), &facts, &waivers, TODAY); @@ -1291,8 +1301,8 @@ mod tests { assert_eq!(f.status, DeploymentFindingStatus::Waived); assert_eq!(f.severity, FindingError); assert_eq!( - f.waiver.as_ref().unwrap().reason, - "synthetic test waiver".to_string() + f.waiver.as_ref().unwrap().reference, + "OPS-TEST-1290".to_string() ); // An active waiver is reported in the waivers list. assert_eq!(evaluation.active_waivers.len(), 1); @@ -1313,7 +1323,8 @@ mod tests { let id = "relay.admin.public_exposure"; let waivers = [WaiverInput { finding: id.to_string(), - reason: "synthetic readiness waiver".to_string(), + reference: "OPS-TEST-1320".to_string(), + summary: Some("Synthetic readiness waiver".to_string()), expires: FUTURE.to_string(), }]; let evaluation = evaluate(Some(DeploymentProfile::Production), &facts, &waivers, TODAY); @@ -1337,7 +1348,8 @@ mod tests { }; let waivers = [WaiverInput { finding: "relay.openapi.public".to_string(), - reason: "synthetic expired waiver".to_string(), + reference: "OPS-TEST-1344".to_string(), + summary: Some("Synthetic expired waiver".to_string()), expires: PAST.to_string(), }]; let evaluation = evaluate(Some(DeploymentProfile::Production), &facts, &waivers, TODAY); @@ -1359,7 +1371,8 @@ mod tests { }; let waivers = [WaiverInput { finding: "relay.config.unsigned".to_string(), - reason: "synthetic attempt to waive a startup gate".to_string(), + reference: "OPS-TEST-1366".to_string(), + summary: Some("Synthetic attempt to waive a startup gate".to_string()), expires: FUTURE.to_string(), }]; let evaluation = evaluate( @@ -1386,7 +1399,8 @@ mod tests { }; let waivers = [WaiverInput { finding: "relay.openapi.public".to_string(), - reason: "synthetic boundary waiver".to_string(), + reference: "OPS-TEST-1393".to_string(), + summary: Some("Synthetic boundary waiver".to_string()), expires: TODAY.to_string(), }]; let evaluation = evaluate(Some(DeploymentProfile::Production), &facts, &waivers, TODAY); diff --git a/crates/registry-relay/src/main.rs b/crates/registry-relay/src/main.rs index 679ae95ba..829150f59 100644 --- a/crates/registry-relay/src/main.rs +++ b/crates/registry-relay/src/main.rs @@ -4010,12 +4010,19 @@ consultation: .await .expect("test listener binds"); let addr = listener.local_addr().expect("listener has local addr"); - drop(listener); let url = format!("http://{addr}/healthz"); + let peer = tokio::spawn(async move { + let (connection, _) = listener.accept().await.expect("test peer accepts"); + drop(connection); + }); let err = run_healthcheck(&url, Duration::from_millis(200)) .await .expect_err("healthcheck fails"); + tokio::time::timeout(Duration::from_secs(1), peer) + .await + .expect("healthcheck contacts the test peer") + .expect("test peer joins"); assert!( err.to_string().contains("request failed"), "unexpected error: {err}" diff --git a/crates/registry-relay/src/state_plane/migration.rs b/crates/registry-relay/src/state_plane/migration.rs index d3cbf9db0..e2e790e35 100644 --- a/crates/registry-relay/src/state_plane/migration.rs +++ b/crates/registry-relay/src/state_plane/migration.rs @@ -31,14 +31,14 @@ const STATE_PLANE_SCHEMA_IDENTITY_PREIMAGE_V1: &str = concat!( "materialization-publication=registry.relay.postgres-materialization-publication/v1\0", "materialization-publication-order=attempt-before-access-atomic-completion-pointer-monotonic-generation-v1\0", "consultation-completion=atomic-intent-sealed-seed-closed-three-outcomes-script-verification-permit-dynamic-ordinal-call-ack-known-unfinished-recovery-date-schema-v5\0", - "consultation-batch-child-replay=authenticated-hmac-binding-reserve-before-quota-atomic-terminal-publication-fixed-retention-v2\0", + "consultation-batch-child-replay=authenticated-hmac-binding-reserve-before-quota-atomic-terminal-publication-provenance-without-consent-fixed-retention-v3\0", "consultation-authorization=database-expiry-credential-verification-data-order-keyed-request-effect-call-ack-v4\0", "consultation-credentials=direct-data-auth-reference-distinct-authored-verification-no-expiry-v3\0", "serving-fence-order=fence-row-keyring-intent-permit-audit-head-v1\0", "key-order=utf8-bytewise-key-order-v1\0", ); pub(crate) const STATE_PLANE_SCHEMA_FINGERPRINT_V1: &str = - "sha256:a70a81bb46e49a97d24d8fec0716938639e760ced8834eba7965aa423dc5431b"; + "sha256:de5f85061217001d7e37fe5681aec6cdaa36b01072fca97dda609485e8938713"; pub(super) const MIGRATION_ADVISORY_LOCK_KEY_V1: i64 = 7_221_091_440; const SUPPORTED_POSTGRES_MIN_MAJOR: i32 = 16; @@ -47,16 +47,16 @@ const SUPPORTED_POSTGRES_MAX_MAJOR: i32 = 18; // Filled from the semantic catalog descriptor below on disposable supported // PostgreSQL majors. Constraint rendering is explicitly versioned because // pg_get_constraintdef is not a cross-major wire contract. -const CONSTRAINT_FINGERPRINT_PG16: &str = "9496b43b708e2f6358200899c00eaca1"; -const CONSTRAINT_FINGERPRINT_PG17: &str = "9496b43b708e2f6358200899c00eaca1"; -const CONSTRAINT_FINGERPRINT_PG18: &str = "50b7c98eb7274c63d10c7b84a41f33f8"; +const CONSTRAINT_FINGERPRINT_PG16: &str = "25dbf98e1e26411436c24a391b534e07"; +const CONSTRAINT_FINGERPRINT_PG17: &str = "25dbf98e1e26411436c24a391b534e07"; +const CONSTRAINT_FINGERPRINT_PG18: &str = "a85469cded76f1f5f72f674c09fc08b3"; const COLUMN_FINGERPRINT_PG16: &str = "f1cce8b8398fd1b177d3d6b61a112cec"; const COLUMN_FINGERPRINT_PG17: &str = "f1cce8b8398fd1b177d3d6b61a112cec"; const COLUMN_FINGERPRINT_PG18: &str = "f1cce8b8398fd1b177d3d6b61a112cec"; -const FUNCTION_FINGERPRINT_PG16: &str = "5c72567f21536f434bfd5f21ab7fddf7"; -const FUNCTION_FINGERPRINT_PG17: &str = "5c72567f21536f434bfd5f21ab7fddf7"; -const FUNCTION_FINGERPRINT_PG18: &str = "5c72567f21536f434bfd5f21ab7fddf7"; -const CAPABILITY_HELPER_BODY_FINGERPRINT_V1: &str = "31835c50f5c67b30e888fa8d6b8270a5"; +const FUNCTION_FINGERPRINT_PG16: &str = "974de91ab404047b05d2fbe76ab3bc45"; +const FUNCTION_FINGERPRINT_PG17: &str = "974de91ab404047b05d2fbe76ab3bc45"; +const FUNCTION_FINGERPRINT_PG18: &str = "974de91ab404047b05d2fbe76ab3bc45"; +const CAPABILITY_HELPER_BODY_FINGERPRINT_V1: &str = "a16f5fe68a6a6751ecb0f935bf987059"; /// Runtime-forceable session semantics. Server/SUSET state that the runtime /// cannot safely repair is rejected by the attested SQL capability instead. @@ -112,7 +112,7 @@ CREATE TABLE IF NOT EXISTS relay_state_private.state_plane_metadata ( ), CONSTRAINT state_plane_metadata_fingerprint_check CHECK ( capability_fingerprint = - 'sha256:a70a81bb46e49a97d24d8fec0716938639e760ced8834eba7965aa423dc5431b' + 'sha256:de5f85061217001d7e37fe5681aec6cdaa36b01072fca97dda609485e8938713' ), CONSTRAINT state_plane_metadata_roles_distinct_check CHECK ( owner_role_oid <> runtime_role_oid @@ -1547,7 +1547,7 @@ WITH metadata AS ( AND schema_version = 1 AND capability_id = 'registry.relay.postgres-durable-audit/v1' AND capability_fingerprint = - 'sha256:a70a81bb46e49a97d24d8fec0716938639e760ced8834eba7965aa423dc5431b' + 'sha256:de5f85061217001d7e37fe5681aec6cdaa36b01072fca97dda609485e8938713' AND serving_fence_capability_id = 'registry.relay.postgres-serving-fence/v1' AND serving_fence_lock_key <> 0 AND serving_fence_lock_key <> 7221091440 @@ -2199,9 +2199,9 @@ SELECT ) ) AND (SELECT value = CASE server.major - WHEN 16 THEN '9496b43b708e2f6358200899c00eaca1' - WHEN 17 THEN '9496b43b708e2f6358200899c00eaca1' - WHEN 18 THEN '50b7c98eb7274c63d10c7b84a41f33f8' + WHEN 16 THEN '25dbf98e1e26411436c24a391b534e07' + WHEN 17 THEN '25dbf98e1e26411436c24a391b534e07' + WHEN 18 THEN 'a85469cded76f1f5f72f674c09fc08b3' ELSE '' END FROM constraint_fingerprint, server) AND (SELECT value = CASE server.major WHEN 16 THEN 'f1cce8b8398fd1b177d3d6b61a112cec' @@ -2209,9 +2209,9 @@ SELECT WHEN 18 THEN 'f1cce8b8398fd1b177d3d6b61a112cec' ELSE '' END FROM column_fingerprint, server) AND (SELECT value = CASE server.major - WHEN 16 THEN '5c72567f21536f434bfd5f21ab7fddf7' - WHEN 17 THEN '5c72567f21536f434bfd5f21ab7fddf7' - WHEN 18 THEN '5c72567f21536f434bfd5f21ab7fddf7' + WHEN 16 THEN '974de91ab404047b05d2fbe76ab3bc45' + WHEN 17 THEN '974de91ab404047b05d2fbe76ab3bc45' + WHEN 18 THEN '974de91ab404047b05d2fbe76ab3bc45' ELSE '' END FROM function_fingerprint, server); $function$; @@ -7327,11 +7327,11 @@ CREATE TABLE IF NOT EXISTS relay_state_private.consultation_batch_child_replay ( AND jsonb_typeof(terminal_payload -> 'provenance') = 'object' AND (terminal_payload -> 'provenance') ?& ARRAY[ 'acquired_at', 'source_observed_at', 'source_revision', - 'acquisition_class', 'integration', 'consent' + 'acquisition_class', 'integration' ]::text[] AND ((terminal_payload -> 'provenance') - ARRAY[ 'acquired_at', 'source_observed_at', 'source_revision', - 'acquisition_class', 'integration', 'snapshot', 'consent' + 'acquisition_class', 'integration', 'snapshot' ]::text[]) = '{}'::jsonb AND jsonb_typeof(terminal_payload #> '{provenance,acquired_at}') = 'string' AND jsonb_typeof(terminal_payload #> '{provenance,source_observed_at}') @@ -7353,25 +7353,6 @@ CREATE TABLE IF NOT EXISTS relay_state_private.consultation_batch_child_replay ( trunc((terminal_payload #>> '{provenance,integration,revision}')::numeric) AND (terminal_payload #>> '{provenance,integration,revision}')::numeric BETWEEN 1 AND 9999999999 - AND jsonb_typeof(terminal_payload #> '{provenance,consent}') = 'object' - AND (terminal_payload #> '{provenance,consent}') ?& ARRAY[ - 'outcome', 'verifier_id', 'verifier_revision', 'checked_at', 'expires_at', - 'revocation_status' - ]::text[] - AND ((terminal_payload #> '{provenance,consent}') - ARRAY[ - 'outcome', 'verifier_id', 'verifier_revision', 'checked_at', 'expires_at', - 'revocation_status' - ]::text[]) = '{}'::jsonb - AND jsonb_typeof(terminal_payload #> '{provenance,consent,outcome}') = 'string' - AND jsonb_typeof(terminal_payload #> '{provenance,consent,verifier_id}') - IN ('string', 'null') - AND jsonb_typeof(terminal_payload #> '{provenance,consent,verifier_revision}') - IN ('string', 'null') - AND jsonb_typeof(terminal_payload #> '{provenance,consent,checked_at}') - IN ('string', 'null') - AND jsonb_typeof(terminal_payload #> '{provenance,consent,expires_at}') - IN ('string', 'null') - AND jsonb_typeof(terminal_payload #> '{provenance,consent,revocation_status}') = 'string' AND ( (terminal_payload #>> '{provenance,acquisition_class}' IN ( 'source_projected_exact', 'bounded_full_record' @@ -7559,11 +7540,11 @@ BEGIN OR jsonb_typeof(v_terminal -> 'provenance') <> 'object' OR NOT (v_terminal -> 'provenance') ?& ARRAY[ 'acquired_at', 'source_observed_at', 'source_revision', - 'acquisition_class', 'integration', 'consent' + 'acquisition_class', 'integration' ]::text[] OR ((v_terminal -> 'provenance') - ARRAY[ 'acquired_at', 'source_observed_at', 'source_revision', - 'acquisition_class', 'integration', 'snapshot', 'consent' + 'acquisition_class', 'integration', 'snapshot' ]::text[]) <> '{}'::jsonb OR jsonb_typeof(v_terminal #> '{provenance,acquired_at}') <> 'string' OR jsonb_typeof(v_terminal #> '{provenance,source_observed_at}') @@ -7585,25 +7566,6 @@ BEGIN trunc((v_terminal #>> '{provenance,integration,revision}')::numeric) OR (v_terminal #>> '{provenance,integration,revision}')::numeric NOT BETWEEN 1 AND 9999999999 - OR jsonb_typeof(v_terminal #> '{provenance,consent}') <> 'object' - OR NOT (v_terminal #> '{provenance,consent}') ?& ARRAY[ - 'outcome', 'verifier_id', 'verifier_revision', 'checked_at', 'expires_at', - 'revocation_status' - ]::text[] - OR ((v_terminal #> '{provenance,consent}') - ARRAY[ - 'outcome', 'verifier_id', 'verifier_revision', 'checked_at', 'expires_at', - 'revocation_status' - ]::text[]) <> '{}'::jsonb - OR jsonb_typeof(v_terminal #> '{provenance,consent,outcome}') <> 'string' - OR jsonb_typeof(v_terminal #> '{provenance,consent,verifier_id}') - NOT IN ('string', 'null') - OR jsonb_typeof(v_terminal #> '{provenance,consent,verifier_revision}') - NOT IN ('string', 'null') - OR jsonb_typeof(v_terminal #> '{provenance,consent,checked_at}') - NOT IN ('string', 'null') - OR jsonb_typeof(v_terminal #> '{provenance,consent,expires_at}') - NOT IN ('string', 'null') - OR jsonb_typeof(v_terminal #> '{provenance,consent,revocation_status}') <> 'string' OR ( (v_terminal #>> '{provenance,acquisition_class}' IN ( 'source_projected_exact', 'bounded_full_record' @@ -8658,7 +8620,7 @@ mod tests { "'schema', 'consultation_id', 'outcome', 'outputs', 'profile', 'provenance'", "'id', 'contract_hash'", "'acquired_at', 'source_observed_at', 'source_revision'", - "'acquisition_class', 'integration', 'snapshot', 'consent'", + "'acquisition_class', 'integration', 'snapshot'", "ARRAY['id', 'revision']::text[]", "ARRAY['generation_id', 'published_at']::text[]", "'outputs'", @@ -8675,6 +8637,7 @@ mod tests { "integration_pack", "policy_hash", "snapshot_generation_id", + "'consent'", ] { assert!( !section.contains(obsolete), @@ -8934,7 +8897,7 @@ mod tests { AUDIT_PSEUDONYM_KEYRING_CAPABILITY_V1, MATERIALIZATION_PUBLICATION_CAPABILITY_V1, "atomic-intent-sealed-seed-closed-three-outcomes-script-verification-permit-dynamic-ordinal-call-ack-known-unfinished-recovery-date-schema-v5", - "authenticated-hmac-binding-reserve-before-quota-atomic-terminal-publication-fixed-retention-v2", + "authenticated-hmac-binding-reserve-before-quota-atomic-terminal-publication-provenance-without-consent-fixed-retention-v3", "database-expiry-credential-verification-data-order-keyed-request-effect-call-ack-v4", "direct-data-auth-reference-distinct-authored-verification-no-expiry-v3", "utf8-bytewise-key-order-v1", diff --git a/crates/registry-relay/src/state_plane/postgres_tests.rs b/crates/registry-relay/src/state_plane/postgres_tests.rs index b427d3e16..0172197e9 100644 --- a/crates/registry-relay/src/state_plane/postgres_tests.rs +++ b/crates/registry-relay/src/state_plane/postgres_tests.rs @@ -1859,8 +1859,7 @@ async fn exercise_batch_child_replay_reservation_contract( "source_observed_at": null, "source_revision": null, "acquisition_class": "source_projected_exact", - "integration": {"id": "synthetic.pack", "revision": 1}, - "consent": {"outcome": "not_required", "verifier_id": null, "verifier_revision": null, "checked_at": null, "expires_at": null, "revocation_status": "not_applicable"} + "integration": {"id": "synthetic.pack", "revision": 1} } }))?; let mismatch = admin @@ -2074,6 +2073,7 @@ async fn exercise_batch_terminal_publication_contract( replay_json["notary_evaluation_id"], first_evaluation.to_canonical_string() ); + assert!(replay_json["provenance"].get("consent").is_none()); set_role(admin, owner_role).await?; let after_replay = admin .query_one( diff --git a/crates/registry-relay/tests/config_schema.rs b/crates/registry-relay/tests/config_schema.rs index c55830e65..732a15d7c 100644 --- a/crates/registry-relay/tests/config_schema.rs +++ b/crates/registry-relay/tests/config_schema.rs @@ -7,6 +7,10 @@ use std::process::Command; use jsonschema::{Draft, JSONSchema}; use registry_platform_audit::{AuditChainProfile, AuditError}; +use registry_platform_ops::{ + deployment_waiver_reference_schema_fragment, deployment_waiver_summary_schema_fragment, + validate_deployment_waiver_metadata, DeploymentWaiverMetadataError, +}; use registry_relay::config::schema::{document, document_json, CONFIG_SCHEMA_ID}; use registry_relay::config::Config; use serde_json::{json, Value}; @@ -72,10 +76,38 @@ fn assert_runtime_rejects(instance: &Value, label: &str) { ); } +fn assert_runtime_load_rejects(instance: &Value, label: &str) { + let yaml = serde_norway::to_string(instance) + .unwrap_or_else(|error| panic!("failed to serialize {label} as YAML: {error}")); + if let Ok(config) = serde_norway::from_str::(&yaml) { + assert!( + registry_relay::config::validate::run(&config).is_err(), + "{label} must be rejected during runtime validation" + ); + } +} + fn example_config() -> Value { parse_yaml(&relay_root().join("config/example.yaml")) } +fn config_with_deployment_waiver(reference: &str, summary: Option<&str>) -> Value { + let mut config = example_config(); + let mut waiver = json!({ + "finding": "relay.config.unsigned", + "reference": reference, + "expires": "2999-01-01" + }); + if let Some(summary) = summary { + waiver["summary"] = json!(summary); + } + config["deployment"] = json!({ + "profile": "hosted_lab", + "waivers": [waiver] + }); + config +} + fn oidc_config() -> Value { parse_yaml(&relay_root().join("config/example.oidc.yaml")) } @@ -252,6 +284,125 @@ fn strict_nested_objects_tagged_variants_and_duration_shapes_are_enforced() { assert_invalid(&schema, &duration_object, "object-form duration"); } +#[test] +fn deployment_waiver_schema_rejects_retired_and_noncanonical_metadata() { + let schema = document(); + let mut config = example_config(); + config["deployment"] = json!({ + "profile": "hosted_lab", + "waivers": [{ + "finding": "relay.config.unsigned", + "reference": "OPS..42", + "expires": "2999-01-01" + }] + }); + assert_invalid(&schema, &config, "waiver reference containing '..'"); + + config["deployment"]["waivers"][0]["reference"] = json!("OPS-42"); + config["deployment"]["waivers"][0]["summary"] = Value::Null; + assert_invalid(&schema, &config, "null deployment waiver summary"); + assert_runtime_load_rejects(&config, "null deployment waiver summary"); + + config["deployment"]["waivers"][0] + .as_object_mut() + .expect("waiver is an object") + .remove("summary"); + config["deployment"]["waivers"][0]["reason"] = json!("retired waiver text"); + assert_invalid(&schema, &config, "retired deployment waiver reason"); + assert_runtime_load_rejects(&config, "retired deployment waiver reason"); +} + +#[test] +fn deployment_waiver_schema_matches_shared_portable_metadata_contract() { + let schema = document(); + assert_eq!( + schema.pointer("/$defs/DeploymentWaiverReference"), + Some(&deployment_waiver_reference_schema_fragment()) + ); + assert_eq!( + schema.pointer("/$defs/DeploymentWaiverSummary"), + Some(&deployment_waiver_summary_schema_fragment()) + ); + + for reference in ["OPS-42", "Bearer:", "Authorization:Basic:"] { + validate_deployment_waiver_metadata(reference, None).unwrap_or_else(|error| { + panic!("runtime rejected valid reference {reference:?}: {error}") + }); + assert_valid( + &schema, + &config_with_deployment_waiver(reference, None), + &format!("portable deployment waiver reference {reference:?}"), + ); + } + for reference in ["Bearer:abcdef", "authorization:bAsIc:abc123", "Bearer::"] { + assert_eq!( + validate_deployment_waiver_metadata(reference, None), + Err(DeploymentWaiverMetadataError::ReferenceCredentialLiteral) + ); + assert_invalid( + &schema, + &config_with_deployment_waiver(reference, None), + &format!("credential-shaped deployment waiver reference {reference:?}"), + ); + } + + for summary in [ + "Ordinary operator summary".to_string(), + "\u{feff}summary\u{feff}".to_string(), + "summary\u{2028}continued".to_string(), + "é".repeat(256), + ] { + validate_deployment_waiver_metadata("OPS-42", Some(&summary)) + .unwrap_or_else(|error| panic!("runtime rejected valid summary {summary:?}: {error}")); + assert_valid( + &schema, + &config_with_deployment_waiver("OPS-42", Some(&summary)), + "structurally valid deployment waiver summary", + ); + } + for summary in [ + String::new(), + " summary".to_string(), + "summary\u{3000}".to_string(), + "summary\u{001f}continued".to_string(), + "é".repeat(257), + ] { + assert!( + validate_deployment_waiver_metadata("OPS-42", Some(&summary)).is_err(), + "runtime must reject structurally invalid summary {summary:?}" + ); + assert_invalid( + &schema, + &config_with_deployment_waiver("OPS-42", Some(&summary)), + "structurally invalid deployment waiver summary", + ); + } + + for summary in [ + "Authorization: "Bearer abcdef"", + concat!("accidentally pasted -----BEGIN PRIVATE ", "KEY-----"), + concat!( + "accidentally pasted -----BEGIN PGP PRIVATE KEY ", + "BLOCK-----" + ), + ] { + assert_eq!( + validate_deployment_waiver_metadata("OPS-42", Some(summary)), + Err(DeploymentWaiverMetadataError::SummaryCredentialLiteral) + ); + let config = config_with_deployment_waiver("OPS-42", Some(summary)); + assert_valid( + &schema, + &config, + "contextual waiver summary left to semantic validation", + ); + assert_runtime_load_rejects( + &config, + "contextual waiver summary rejected by semantic validation", + ); + } +} + #[test] fn socket_addresses_match_schema_and_runtime_portable_syntax() { let schema = document(); diff --git a/crates/registry-relay/tests/deployment_profile_gates.rs b/crates/registry-relay/tests/deployment_profile_gates.rs index 4f50bd757..b892e508a 100644 --- a/crates/registry-relay/tests/deployment_profile_gates.rs +++ b/crates/registry-relay/tests/deployment_profile_gates.rs @@ -8,7 +8,7 @@ //! wiring that only an integration test can reach: //! //! * an invalid profile value fails config parse, -//! * deployment waivers must carry a non-empty reason and a well-formed expiry, +//! * deployment waivers must carry validated metadata and a well-formed expiry, //! * `evidence_grade` from an unsigned local file refuses startup, //! * the audit write-policy hook behaves under the default `fail_closed` //! policy and explicit `availability_first` opt-out, proven with an @@ -113,22 +113,106 @@ fn each_declared_profile_value_parses() { } #[test] -fn waiver_missing_reason_is_rejected() { +fn waiver_missing_reference_is_rejected_at_parse() { let yaml = format!( - "{}\ndeployment:\n profile: hosted_lab\n waivers:\n - finding: relay.config.unsigned\n reason: \"\"\n expires: \"2999-01-01\"\n", + "{}\ndeployment:\n profile: hosted_lab\n waivers:\n - finding: relay.config.unsigned\n expires: \"2999-01-01\"\n", minimal_config_yaml() ); - let config = parse_config(&yaml).expect("config parses"); assert!( - config::validate::run(&config).is_err(), - "a waiver with an empty reason must fail validation" + parse_config(&yaml).is_err(), + "a waiver without the required reference must fail deserialization" + ); +} + +#[test] +fn legacy_waiver_reason_is_rejected_as_unknown() { + let yaml = format!( + "{}\ndeployment:\n profile: hosted_lab\n waivers:\n - finding: relay.config.unsigned\n reference: OPS-42\n reason: \"legacy waiver text\"\n expires: \"2999-01-01\"\n", + minimal_config_yaml() + ); + assert!( + parse_config(&yaml).is_err(), + "the removed reason field must fail strict deserialization" + ); +} + +#[test] +fn invalid_or_overlong_waiver_reference_is_rejected() { + for reference in ["", " OPS-42", "OPS/42", &"x".repeat(129)] { + let yaml = format!( + "{}\ndeployment:\n profile: hosted_lab\n waivers:\n - finding: relay.config.unsigned\n reference: \"{reference}\"\n expires: \"2999-01-01\"\n", + minimal_config_yaml() + ); + let config = parse_config(&yaml).expect("reference value parses as YAML"); + assert!( + config::validate::run(&config).is_err(), + "invalid reference must fail validation: {reference:?}" + ); + } +} + +#[test] +fn absent_summary_and_ordinary_summary_are_accepted() { + for summary_line in [ + "".to_string(), + " summary: \"Gateway rate limiting is tracked in OPS-42\"\n".to_string(), + ] { + let yaml = format!( + "{}\ndeployment:\n profile: hosted_lab\n waivers:\n - finding: relay.config.unsigned\n reference: OPS-42\n{summary_line} expires: \"2999-01-01\"\n", + minimal_config_yaml() + ); + let config = parse_config(&yaml).expect("config parses"); + config::validate::run(&config).expect("valid waiver metadata is accepted"); + } +} + +#[test] +fn explicit_null_waiver_summary_is_rejected() { + let yaml = format!( + "{}\ndeployment:\n profile: hosted_lab\n waivers:\n - finding: relay.config.unsigned\n reference: OPS-42\n summary: null\n expires: \"2999-01-01\"\n", + minimal_config_yaml() + ); + assert!( + parse_config(&yaml).is_err(), + "summary must be a string when present, not null" + ); +} + +#[test] +fn invalid_waiver_summary_is_rejected() { + for summary in [ + "", + " summary", + "Bearer credential-value", + "Basic credential-value", + "rotated leaked Bearer abcdef", + "-----BEGIN PRIVATE KEY-----", + concat!("-----BEGIN PGP PRIVATE KEY ", "BLOCK-----"), + &"x".repeat(257), + ] { + let yaml = format!( + "{}\ndeployment:\n profile: hosted_lab\n waivers:\n - finding: relay.config.unsigned\n reference: OPS-42\n summary: \"{summary}\"\n expires: \"2999-01-01\"\n", + minimal_config_yaml() + ); + let config = parse_config(&yaml).expect("summary value parses as YAML"); + assert!( + config::validate::run(&config).is_err(), + "invalid summary must fail validation: {summary:?}" + ); + } + + let control_yaml = format!( + "{}\ndeployment:\n profile: hosted_lab\n waivers:\n - finding: relay.config.unsigned\n reference: OPS-42\n summary: \"summary\\twith control\"\n expires: \"2999-01-01\"\n", + minimal_config_yaml() ); + let config = parse_config(&control_yaml).expect("escaped control parses"); + assert!(config::validate::run(&config).is_err()); } #[test] fn waiver_bad_expiry_is_rejected() { let yaml = format!( - "{}\ndeployment:\n profile: hosted_lab\n waivers:\n - finding: relay.config.unsigned\n reason: \"synthetic-waiver-not-a-secret\"\n expires: \"soon\"\n", + "{}\ndeployment:\n profile: hosted_lab\n waivers:\n - finding: relay.config.unsigned\n reference: OPS-TEST-EXPIRY\n expires: \"soon\"\n", minimal_config_yaml() ); let config = parse_config(&yaml).expect("config parses"); @@ -141,7 +225,7 @@ fn waiver_bad_expiry_is_rejected() { #[test] fn waiver_with_valid_expiry_passes_validation() { let yaml = format!( - "{}\ndeployment:\n profile: hosted_lab\n waivers:\n - finding: relay.config.unsigned\n reason: \"synthetic-waiver-not-a-secret\"\n expires: \"2999-01-01\"\n", + "{}\ndeployment:\n profile: hosted_lab\n waivers:\n - finding: relay.config.unsigned\n reference: OPS-TEST-VALID\n expires: \"2999-01-01\"\n", minimal_config_yaml() ); let config = parse_config(&yaml).expect("config parses"); @@ -233,7 +317,7 @@ fn governed_candidate_apply_accepts_evidence_grade_with_signed_provenance() { #[tokio::test] async fn boot_audit_records_waived_gate() { let yaml = format!( - "{}\ndeployment:\n profile: hosted_lab\n waivers:\n - finding: relay.config.unsigned\n reason: \"synthetic-waiver-not-a-secret\"\n expires: \"2999-01-01\"\n", + "{}\ndeployment:\n profile: hosted_lab\n waivers:\n - finding: relay.config.unsigned\n reference: OPS-TEST-AUDIT\n expires: \"2999-01-01\"\n", minimal_config_yaml() ); let config = parse_config(&yaml).expect("config parses"); @@ -290,7 +374,7 @@ async fn boot_audit_writes_nothing_without_waivers() { #[tokio::test] async fn boot_audit_failure_fails_closed_for_waived_gate() { let yaml = format!( - "{}\ndeployment:\n profile: hosted_lab\n waivers:\n - finding: relay.config.unsigned\n reason: \"synthetic-waiver-not-a-secret\"\n expires: \"2999-01-01\"\n", + "{}\ndeployment:\n profile: hosted_lab\n waivers:\n - finding: relay.config.unsigned\n reference: OPS-TEST-FAIL-CLOSED\n expires: \"2999-01-01\"\n", minimal_config_yaml() ); let config = parse_config(&yaml).expect("config parses"); @@ -314,7 +398,7 @@ async fn boot_audit_failure_fails_closed_for_waived_gate() { #[tokio::test] async fn boot_audit_failure_is_best_effort_when_availability_first() { let yaml = format!( - "{}\ndeployment:\n profile: hosted_lab\n waivers:\n - finding: relay.config.unsigned\n reason: \"synthetic-waiver-not-a-secret\"\n expires: \"2999-01-01\"\n", + "{}\ndeployment:\n profile: hosted_lab\n waivers:\n - finding: relay.config.unsigned\n reference: OPS-TEST-AVAILABILITY\n expires: \"2999-01-01\"\n", minimal_config_yaml() ); let mut config = parse_config(&yaml).expect("config parses"); diff --git a/crates/registryctl/CHANGELOG.md b/crates/registryctl/CHANGELOG.md index 86d7c31b7..4c4e27d19 100644 --- a/crates/registryctl/CHANGELOG.md +++ b/crates/registryctl/CHANGELOG.md @@ -6,6 +6,23 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] +### Changed + +- The maintained project-authoring fixtures now keep Relay source adaptation, + Notary evidence policy, and consumer decisions separate. The + DHIS2 Tracker starter retains its bounded health-evidence contract while + distinguishing positive, negative, unknown, no-match, ambiguity, and source + failure. Snapshot and custom-system examples now model reusable evidence or + explicitly source-owned decisions instead of Notary-owned eligibility. +- Smoke reports now carry the `registryctl.smoke.v1` schema version and are + validated against a committed JSON Schema. + +### Removed + +- Removed the hidden `registryctl init spreadsheet-api` compatibility alias. + Use `registryctl init relay` for the local spreadsheet tutorial or + `registryctl init --from` for Registry Stack project authoring. + ## [0.12.2] - 2026-07-20 - No user-visible Registryctl changes. This release fixes forward from the diff --git a/crates/registryctl/README.md b/crates/registryctl/README.md index ef4abaf3f..5532ad678 100644 --- a/crates/registryctl/README.md +++ b/crates/registryctl/README.md @@ -28,9 +28,10 @@ Interactive report commands print concise human-readable results. Add `--format another program needs a versioned report. Artifact and protocol commands, including authoring schemas, editor metadata, the language server, and logs, retain their native output formats. -The generated project contains a local Registry Relay configuration, sample -XLSX workbook, Compose file, project manifest, local demo credentials, and an -optional Bruno API collection. +The initialization report identifies the project root and supported generated +entry points. Automation must use the versioned JSON report rather than +hard-code the local tutorial's generated directory layout, which remains an +implementation detail. Run `registryctl doctor` before starting a generated stack or after editing config. It calls the product-owned validators and redacts local secret values. Add `--format json` when another program @@ -131,10 +132,10 @@ Registryctl never searches the current working directory for a lock, and rejects a missing, mismatched, oversized, symlinked, or structurally invalid file. -Existing projects do not need the lock for `start`, `stop`, `status`, or other -runtime commands. They keep using the immutable image references already stored -in `registryctl.yaml` and `compose.yaml`. A later `init` or `add` is a generation -operation and requires the lock for that registryctl version. +Existing local tutorial projects do not need the lock for `start`, `stop`, +`status`, or other runtime commands. Those commands use the immutable image +references already written into the project. A later `init` or `add` is a +generation operation and requires the lock for that registryctl version. ## Update checks diff --git a/crates/registryctl/schemas/registryctl.smoke.v1.schema.json b/crates/registryctl/schemas/registryctl.smoke.v1.schema.json new file mode 100644 index 000000000..6fc5f9f99 --- /dev/null +++ b/crates/registryctl/schemas/registryctl.smoke.v1.schema.json @@ -0,0 +1,71 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://id.registrystack.org/schemas/registryctl/registryctl.smoke.v1.schema.json", + "title": "registryctl smoke report v1", + "type": "object", + "required": ["schema_version", "base_url", "passed", "checks"], + "additionalProperties": false, + "properties": { + "schema_version": { + "const": "registryctl.smoke.v1" + }, + "base_url": { + "type": "string", + "minLength": 1 + }, + "passed": { + "type": "boolean" + }, + "checks": { + "type": "array", + "items": { + "$ref": "#/$defs/check" + } + } + }, + "$defs": { + "check": { + "type": "object", + "required": [ + "name", + "method", + "path", + "expected_status", + "actual_status", + "passed", + "error" + ], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "method": { + "type": "string", + "minLength": 1 + }, + "path": { + "type": "string", + "pattern": "^/" + }, + "expected_status": { + "type": "integer", + "minimum": 100, + "maximum": 599 + }, + "actual_status": { + "type": ["integer", "null"], + "minimum": 100, + "maximum": 599 + }, + "passed": { + "type": "boolean" + }, + "error": { + "type": ["string", "null"] + } + } + } + } +} diff --git a/crates/registryctl/src/lib.rs b/crates/registryctl/src/lib.rs index e580fb653..4b2c1b918 100644 --- a/crates/registryctl/src/lib.rs +++ b/crates/registryctl/src/lib.rs @@ -80,6 +80,8 @@ const CONFIG_BUNDLE_SIGNATURE_SCHEMA: &str = "registry.platform.config_bundle_si const CONFIG_TRUST_ANCHOR_SCHEMA: &str = "registry.platform.config_trust_anchor.v1"; const INIT_REPORT_SCHEMA_VERSION: &str = "registryctl.init.v1"; const ADD_NOTARY_REPORT_SCHEMA_VERSION: &str = "registryctl.add_notary.v1"; +pub const SMOKE_REPORT_SCHEMA_V1: &str = + include_str!("../schemas/registryctl.smoke.v1.schema.json"); #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "snake_case")] @@ -3667,13 +3669,22 @@ fn relay_config(credentials: &LocalCredentials) -> String { } #[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] struct SmokeReport { + schema_version: SmokeReportSchema, base_url: String, passed: bool, checks: Vec, } +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +enum SmokeReportSchema { + #[serde(rename = "registryctl.smoke.v1")] + V1, +} + #[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] struct SmokeCheck { name: String, method: String, @@ -3795,6 +3806,7 @@ fn run_smoke_checks(base_url: &str, secrets: &LocalEnv) -> SmokeReport { ); SmokeReport { + schema_version: SmokeReportSchema::V1, base_url: base_url.to_string(), passed: checks.iter().all(|check| check.passed), checks, @@ -5416,9 +5428,42 @@ mod tests { assert!(!json.contains("row-secret")); assert!(!json.contains("identity-secret")); assert!(!report.passed); + assert_eq!(parsed.schema_version, SmokeReportSchema::V1); assert_eq!(parsed.checks.len(), 11); } + #[test] + fn smoke_report_rejects_another_schema_version() { + let secrets = LocalEnv { + values: BTreeMap::new(), + }; + let report = run_smoke_checks("http://127.0.0.1:1", &secrets); + let mut document = serde_json::to_value(report).unwrap(); + document["schema_version"] = serde_json::json!("registryctl.smoke.v2"); + + assert!(parse_smoke_report(&document.to_string()).is_err()); + } + + #[test] + fn smoke_report_json_matches_committed_schema() { + let secrets = LocalEnv { + values: BTreeMap::new(), + }; + let report = run_smoke_checks("http://127.0.0.1:1", &secrets); + let document = serde_json::to_value(report).unwrap(); + let schema: JsonValue = serde_json::from_str(SMOKE_REPORT_SCHEMA_V1).unwrap(); + let compiled = jsonschema::JSONSchema::compile(&schema).expect("schema compiles"); + let validation_errors = match compiled.validate(&document) { + Ok(()) => Vec::new(), + Err(errors) => errors.map(|error| error.to_string()).collect::>(), + }; + + assert!( + validation_errors.is_empty(), + "registryctl smoke report must satisfy its schema: {validation_errors:?}" + ); + } + #[test] fn smoke_project_writes_redacted_failure_report() { let temp = TempDir::new().unwrap(); @@ -5435,6 +5480,7 @@ mod tests { for (_, secret) in env.lines().filter_map(|line| line.split_once('=')) { assert!(!report.contains(secret)); } + assert!(report.contains("\"schema_version\": \"registryctl.smoke.v1\"")); assert!(report.contains("\"passed\": false")); } diff --git a/crates/registryctl/src/main.rs b/crates/registryctl/src/main.rs index 495930fe2..687efcfa7 100644 --- a/crates/registryctl/src/main.rs +++ b/crates/registryctl/src/main.rs @@ -1,7 +1,7 @@ use std::path::PathBuf; use anyhow::{Context, Result}; -use clap::{Parser, Subcommand, ValueEnum}; +use clap::{error::ErrorKind, CommandFactory, Parser, Subcommand, ValueEnum}; use registryctl::{ AddNotaryReport, AnchorReport, BundleInspectReport, BundleSignOptions, BundleSignReport, @@ -39,8 +39,7 @@ fn main() -> Result<()> { } => { let destination = match (&from, command.as_deref()) { (Some(_), None) => Some(project_dir.as_path()), - (None, Some(InitCommand::Relay { dir, .. })) - | (None, Some(InitCommand::SpreadsheetApi { dir, .. })) => Some(dir.as_path()), + (None, Some(InitCommand::Relay { dir, .. })) => Some(dir.as_path()), _ => None, }; if format == OutputFormat::Json @@ -59,14 +58,20 @@ fn main() -> Result<()> { InitCommand::Relay { dir, sample } => { registryctl::init_spreadsheet_api(&dir, sample, &image_lock)? } - InitCommand::SpreadsheetApi { dir, sample } => { - registryctl::init_spreadsheet_api(&dir, sample, &image_lock)? - } } } - _ => anyhow::bail!( - "init requires exactly one of --from or a legacy product subcommand" - ), + (None, None) => Cli::command() + .error( + ErrorKind::MissingRequiredArgument, + "init requires exactly one of --from or the relay subcommand", + ) + .exit(), + (Some(_), Some(_)) => Cli::command() + .error( + ErrorKind::ArgumentConflict, + "init accepts only one of --from or the relay subcommand", + ) + .exit(), }; match format { OutputFormat::Human => println!("{}", render_init_report(&report)?), @@ -2168,15 +2173,6 @@ enum InitCommand { #[arg(long, value_enum, default_value_t = Sample::Benefits)] sample: Sample, }, - /// Create a local Relay-backed spreadsheet API project. - #[command(name = "spreadsheet-api", hide = true)] - SpreadsheetApi { - /// Directory to create. - dir: PathBuf, - /// Sample project to generate. - #[arg(long, value_enum, default_value_t = Sample::Benefits)] - sample: Sample, - }, } #[derive(Debug, Subcommand)] diff --git a/crates/registryctl/src/project_authoring.rs b/crates/registryctl/src/project_authoring.rs index d603b20fc..641f13379 100644 --- a/crates/registryctl/src/project_authoring.rs +++ b/crates/registryctl/src/project_authoring.rs @@ -21,6 +21,7 @@ use registry_relay::source_plan::{ use serde::{Deserialize, Serialize}; use serde_json::{json, Map, Value}; use sha2::{Digest, Sha256}; +use time::{format_description::well_known::Rfc3339, OffsetDateTime}; static PROJECT_STARTERS: include_dir::Dir<'_> = include_dir::include_dir!("$CARGO_MANIFEST_DIR/assets/project-starters"); diff --git a/crates/registryctl/src/project_authoring/commands.rs b/crates/registryctl/src/project_authoring/commands.rs index 149557e5b..c7a900ab9 100644 --- a/crates/registryctl/src/project_authoring/commands.rs +++ b/crates/registryctl/src/project_authoring/commands.rs @@ -563,22 +563,28 @@ fn offline_private_path( .to_owned()) } +// A remote candidate can have a small NTP offset from the operator host. Thirty +// seconds accommodates that offset without accepting evidence outside this +// single governed request. +const GOVERNED_LIVE_REMOTE_CLOCK_SKEW: time::Duration = time::Duration::seconds(30); + +#[derive(Clone, Copy)] +struct GovernedLiveValidationWindow { + request_started_at: OffsetDateTime, + response_received_at: OffsetDateTime, +} + fn execute_governed_live_test(loaded: &LoadedRegistryProject) -> Result { let environment = loaded .environment_name .as_deref() .ok_or_else(|| anyhow!("live project tests require an environment"))?; - if matches!(environment, "prod" | "production") - || environment.starts_with("prod-") - || environment.ends_with("-prod") - || loaded.environment.as_ref().is_some_and(|environment| { - matches!( - environment.deployment.profile, - DeploymentProfile::Production | DeploymentProfile::EvidenceGrade - ) - }) - { - bail!("live project tests refuse production environments"); + let deployment_profile = loaded + .environment + .as_ref() + .map(|environment| environment.deployment.profile); + if governed_live_environment_is_production(environment, deployment_profile) { + bail!("live project tests refuse production-classified environment names or profiles"); } let origin = std::env::var("REGISTRY_STACK_LIVE_NOTARY_ORIGIN") .context("live Notary origin is absent from the process environment")?; @@ -593,7 +599,7 @@ fn execute_governed_live_test(loaded: &LoadedRegistryProject) -> Result Result Result MAX_LIVE_RESPONSE_BYTES { bail!("governed Notary response exceeded the configured bound"); } let response = parse_json_strict(&response_bytes) .context("governed Notary response was not strict JSON")?; - let returned_claims = validate_live_response(&response, &claims, &expected)?; - Ok(FixtureReport { + let returned_claims = validate_live_response( + &response, + &validated_request, + &expected, + GovernedLiveValidationWindow { + request_started_at, + response_received_at, + }, + )?; + Ok(governed_live_fixture_report(returned_claims)) +} + +fn governed_live_environment_is_production( + environment: &str, + deployment_profile: Option, +) -> bool { + environment + .split(['-', '_', '.']) + .any(|segment| matches!(segment, "prod" | "production")) + || matches!( + deployment_profile, + Some(DeploymentProfile::Production | DeploymentProfile::EvidenceGrade) + ) +} + +fn governed_live_evaluation_request(endpoint: &url::Url, api_key: &str) -> ureq::Request { + ureq::post(endpoint.as_str()) + .set("content-type", "application/json") + .set("accept", registry_notary_core::FORMAT_CLAIM_RESULT_JSON) + .set("x-api-key", api_key) +} + +fn governed_live_fixture_report(returned_claims: Vec) -> FixtureReport { + FixtureReport { integration: "governed-notary-relay".to_string(), fixture: "live-evaluation".to_string(), inputs: Vec::new(), calls: vec!["notary-evaluation".to_string()], outputs: Vec::new(), claims: returned_claims, - outcome: Some("match".to_string()), + // Claim expectations prove disclosed values, not the source-level + // match or no-match outcome that produced them. + outcome: None, expected_error: None, source_access: None, passed: true, failure: None, - }) + } } fn validate_live_relay_readiness(origin: &url::Url) -> Result<()> { @@ -678,8 +718,9 @@ fn validate_live_relay_readiness(origin: &url::Url) -> Result<()> { fn validate_live_response( response: &Value, - requested_claims: &[String], + request: &ValidatedLiveRequest, expected: &Value, + validation_window: GovernedLiveValidationWindow, ) -> Result> { let object = response .as_object() @@ -690,10 +731,11 @@ fn validate_live_response( let results = object["results"] .as_array() .ok_or_else(|| anyhow!("governed Notary response results must be an array"))?; - if results.len() != requested_claims.len() { + if results.len() != request.claims.len() { bail!("governed Notary response did not return every requested claim exactly once"); } - let requested = requested_claims + let requested = request + .claims .iter() .map(String::as_str) .collect::>(); @@ -707,44 +749,316 @@ fn validate_live_response( bail!("live expected-result claims do not exactly match the governed request"); } let mut returned = BTreeSet::new(); + let mut evaluation_id = None; + let mut target_ref = None; + let mut requester_ref = None; for result in results { - let result = result + let result_object = result .as_object() .ok_or_else(|| anyhow!("governed Notary result must be an object"))?; - let claim_id = result - .get("claim_id") - .and_then(Value::as_str) - .ok_or_else(|| anyhow!("governed Notary result lacks a claim_id"))?; + validate_live_result_raw_schema(result, result_object)?; + let result_view: registry_notary_core::ClaimResultView = + serde_json::from_value(result.clone()).map_err(|_| { + anyhow!( + "governed Notary result does not match the closed public claim-result schema" + ) + })?; + if result_view.provenance.schema_version + != registry_notary_core::CLAIM_PROVENANCE_SCHEMA_VERSION + || result_view.provenance.generated_by.entry_type + != registry_notary_core::PROVENANCE_GENERATED_BY_CLAIM_EVALUATION + { + bail!( + "governed Notary result provenance constants do not match the closed public claim-result schema" + ); + } + let generated_by = &result_view.provenance.generated_by; + if generated_by.evaluation_id != result_view.evaluation_id + || generated_by.claim_id != result_view.claim_id + || generated_by.claim_version != result_view.claim_version + { + bail!( + "governed Notary result provenance does not identify the returned claim result" + ); + } + if generated_by.service_id != request.notary_service_id { + bail!("governed Notary result provenance does not identify the selected Notary service"); + } + if generated_by.policy_id.is_some() + || generated_by.policy_version.is_some() + || generated_by.policy_hash.is_some() + { + bail!("governed Notary API-key result carries unexpected named policy provenance"); + } + if evaluation_id + .as_ref() + .is_some_and(|evaluation_id| evaluation_id != &result_view.evaluation_id) + { + bail!("governed Notary response combines results from different evaluations"); + } + evaluation_id.get_or_insert_with(|| result_view.evaluation_id.clone()); + if result_view.format != registry_notary_core::FORMAT_CLAIM_RESULT_JSON { + bail!("governed Notary result has an invalid claim-result format"); + } + validate_live_result_timestamps(&result_view, validation_window)?; + if !result_view.provenance.derived_from.is_empty() { + bail!("governed Notary result provenance derived_from must remain empty"); + } + validate_live_result_reference_handles(&result_view)?; + let result_target_ref = result_object + .get("target_ref") + .ok_or_else(|| anyhow!("governed Notary result lacks its target reference"))?; + if target_ref + .as_ref() + .is_some_and(|target_ref| target_ref != result_target_ref) + { + bail!("governed Notary response combines inconsistent evaluation references"); + } + target_ref.get_or_insert_with(|| result_target_ref.clone()); + let result_requester_ref = result_object.get("requester_ref").cloned(); + if requester_ref + .as_ref() + .is_some_and(|requester_ref| requester_ref != &result_requester_ref) + { + bail!("governed Notary response combines inconsistent evaluation references"); + } + requester_ref.get_or_insert(result_requester_ref); + let claim_id = result_view.claim_id.as_str(); if !requested.contains(claim_id) || !returned.insert(claim_id.to_string()) { bail!("governed Notary response contains an unknown or duplicate claim result"); } + if request.claim_versions.get(claim_id).map(String::as_str) + != Some(result_view.claim_version.as_str()) + { + bail!("governed Notary result claim version does not match the authored project"); + } + if result_view.subject_type != AUTHORED_CLAIM_SUBJECT_TYPE { + bail!("governed Notary result subject type does not match the authored project"); + } let expected_result = expected[claim_id] .as_object() .ok_or_else(|| anyhow!("live expected claim result must be an object"))?; - if expected_result + let expected_keys = expected_result .keys() - .any(|key| !matches!(key.as_str(), "value" | "satisfied" | "disclosure")) - || expected_result.is_empty() + .map(String::as_str) + .collect::>(); + if expected_keys != BTreeSet::from(["disclosure", "satisfied", "value"]) + && expected_keys + != BTreeSet::from(["disclosure", "redacted_fields", "satisfied", "value"]) { - bail!("live expected claim result has an unsupported field"); + bail!( + "live expected claim result must contain value, satisfied, disclosure, and optional redacted_fields" + ); } - for field in expected_result.keys() { - if result.get(field) != expected_result.get(field) { + validate_live_result_redaction( + &result_view, + expected_result.get("redacted_fields"), + expected_result.get("disclosure"), + )?; + for field in ["value", "satisfied", "disclosure"] { + if result_object.get(field) != expected_result.get(field) { bail!("governed Notary disclosed claim result did not match the expected fixture"); } } - if result - .get("provenance") - .and_then(|value| value.pointer("/used/relay_consultation_count")) - .and_then(Value::as_u64) - .is_none_or(|count| count == 0) - { + if result_view.provenance.used.relay_consultation_count == 0 { bail!("governed Notary result lacks source-backed provenance"); } } Ok(returned.into_iter().collect()) } +fn validate_live_result_timestamps( + result: ®istry_notary_core::ClaimResultView, + validation_window: GovernedLiveValidationWindow, +) -> Result<()> { + let issued_at = OffsetDateTime::parse(&result.issued_at, &Rfc3339) + .map_err(|_| anyhow!("governed Notary result timestamps do not match the public date-time schema"))?; + let expires_at = result + .expires_at + .as_deref() + .map(|expires_at| { + OffsetDateTime::parse(expires_at, &Rfc3339).map_err(|_| { + anyhow!( + "governed Notary result timestamps do not match the public date-time schema" + ) + }) + }) + .transpose()?; + if validation_window.response_received_at < validation_window.request_started_at { + bail!("governed Notary validation window is invalid"); + } + let earliest_issued_at = + validation_window.request_started_at - GOVERNED_LIVE_REMOTE_CLOCK_SKEW; + let latest_issued_at = + validation_window.response_received_at + GOVERNED_LIVE_REMOTE_CLOCK_SKEW; + if issued_at < earliest_issued_at || issued_at > latest_issued_at { + bail!("governed Notary result timestamps do not bind to the current live evaluation"); + } + if expires_at.is_some_and(|expires_at| { + expires_at <= validation_window.response_received_at || expires_at <= issued_at + }) { + bail!("governed Notary result timestamps do not bind to the current live evaluation"); + } + Ok(()) +} + +// These properties are optional in the public OpenAPI schema, but their types +// exclude null when present. `expires_at` is intentionally not listed because +// the public schema requires that key and permits an explicit null. +const LIVE_RESULT_OPTIONAL_NON_NULL_PATHS: &[&str] = &[ + "/redacted_fields", + "/requester_ref", + "/requester_ref/identifier_schemes", + "/requester_ref/profile", + "/target_ref/type", + "/target_ref/identifier_schemes", + "/target_ref/profile", + "/provenance/generated_by/policy_id", + "/provenance/generated_by/policy_version", + "/provenance/generated_by/policy_hash", +]; + +const LIVE_RESULT_SCHEMA_EXCLUDED_PATHS: &[&str] = &[ + "/provenance/generated_by/pack_id", + "/provenance/generated_by/pack_version", +]; + +fn validate_live_result_raw_schema( + result: &Value, + result_object: &Map, +) -> Result<()> { + if !result_object.contains_key("expires_at") { + bail!( + "governed Notary result does not match the closed public claim-result schema: expires_at is required" + ); + } + if LIVE_RESULT_OPTIONAL_NON_NULL_PATHS + .iter() + .any(|pointer| result.pointer(pointer).is_some_and(Value::is_null)) + { + bail!("governed Notary result optional public field cannot be null"); + } + if LIVE_RESULT_SCHEMA_EXCLUDED_PATHS + .iter() + .any(|pointer| result.pointer(pointer).is_some()) + { + bail!("governed Notary result exceeds the closed public claim-result schema"); + } + Ok(()) +} + +fn validate_live_result_reference_handles( + result: ®istry_notary_core::ClaimResultView, +) -> Result<()> { + if !is_notary_pseudonymous_handle(&result.target_ref.handle) + || result + .requester_ref + .as_ref() + .is_some_and(|requester| !is_notary_pseudonymous_handle(&requester.handle)) + { + bail!("governed Notary result contains an invalid pseudonymous reference handle"); + } + Ok(()) +} + +fn is_notary_pseudonymous_handle(value: &str) -> bool { + let digest = value + .strip_prefix("rnref:v1:hmac-sha256:") + .or_else(|| value.strip_prefix("rnref:v1:sha256:")); + digest.is_some_and(|digest| { + digest.len() == 64 + && digest + .bytes() + .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f')) + }) +} + +fn validate_live_result_redaction( + result: ®istry_notary_core::ClaimResultView, + expected_redacted_fields: Option<&Value>, + expected_disclosure: Option<&Value>, +) -> Result<()> { + let disclosure = registry_notary_core::DisclosureProfile::parse(&result.disclosure) + .ok_or_else(|| anyhow!("governed Notary result has an invalid disclosure profile"))?; + match disclosure { + registry_notary_core::DisclosureProfile::Redacted => { + let expected_markers = match expected_redacted_fields { + Some(fields) => validate_live_expected_redaction_fields(fields)?, + None => vec![result.claim_id.clone()], + }; + if result.value.is_some() + || result.satisfied.is_some() + || result.redacted_fields != expected_markers + || expected_disclosure.and_then(Value::as_str) != Some("redacted") + { + bail!("governed Notary result violates full-redaction semantics"); + } + } + registry_notary_core::DisclosureProfile::Predicate => { + if !result.redacted_fields.is_empty() || expected_redacted_fields.is_some() { + bail!("governed Notary result exposes a predicate over redacted fields"); + } + let predicate_value = result.value.as_ref().and_then(Value::as_bool); + if predicate_value.is_none() || predicate_value != result.satisfied { + bail!("governed Notary result has invalid predicate evidence semantics"); + } + } + registry_notary_core::DisclosureProfile::Value => { + if expected_redacted_fields.is_some() { + bail!("live expected redacted_fields apply only to a fully redacted claim result"); + } + if result.satisfied != result.value.as_ref().and_then(Value::as_bool) { + bail!("governed Notary result has invalid value evidence semantics"); + } + if result.redacted_fields.is_empty() { + return Ok(()); + } + let Some(value) = result.value.as_ref().and_then(Value::as_object) else { + bail!("governed Notary result has invalid field-redaction semantics"); + }; + let unique = result + .redacted_fields + .iter() + .map(String::as_str) + .collect::>(); + if result.satisfied.is_some() + || result.redacted_fields.len() > MAX_OUTPUTS + || unique.len() != result.redacted_fields.len() + || unique.iter().any(|field| { + !is_live_top_level_redaction_field(field) || value.contains_key(*field) + }) + { + bail!("governed Notary result has invalid field-redaction semantics"); + } + } + } + Ok(()) +} + +fn validate_live_expected_redaction_fields(fields: &Value) -> Result> { + let fields = fields + .as_array() + .filter(|fields| !fields.is_empty() && fields.len() <= MAX_OUTPUTS) + .ok_or_else(|| anyhow!("live expected redacted_fields have an invalid bounded shape"))?; + let mut unique = BTreeSet::new(); + for field in fields { + let field = field + .as_str() + .filter(|field| is_live_top_level_redaction_field(field)) + .ok_or_else(|| anyhow!("live expected redacted_fields have an invalid bounded shape"))?; + if !unique.insert(field.to_string()) { + bail!("live expected redacted_fields have an invalid bounded shape"); + } + } + Ok(unique.into_iter().collect()) +} + +fn is_live_top_level_redaction_field(field: &str) -> bool { + field != "value" + && !field.contains('.') + && validate_stable_id(field, "live expected redacted field").is_ok() +} + fn validate_live_notary_origin(value: &str) -> Result { if value.len() > 2048 || value.trim() != value { bail!("live Notary origin has an invalid bounded shape"); @@ -851,13 +1165,29 @@ mod external_request_reader_tests { } } -fn validate_live_request(loaded: &LoadedRegistryProject, request: &Value) -> Result> { +#[derive(Debug, PartialEq, Eq)] +struct ValidatedLiveRequest { + claims: Vec, + claim_versions: BTreeMap, + notary_service_id: String, +} + +fn validate_live_request( + loaded: &LoadedRegistryProject, + request: &Value, +) -> Result { let object = request .as_object() .ok_or_else(|| anyhow!("live request must be a JSON object"))?; if contains_sensitive_request_key(request) { bail!("live request contains a forbidden credential-like field"); } + let notary_service_id = loaded + .environment + .as_ref() + .and_then(|environment| environment.deployment.notary.as_ref()) + .map(|notary| notary.service.clone()) + .ok_or_else(|| anyhow!("live request environment does not declare a Notary service"))?; let purpose = object .get("purpose") .and_then(Value::as_str) @@ -879,26 +1209,64 @@ fn validate_live_request(loaded: &LoadedRegistryProject, request: &Value) -> Res bail!("live request claim count is outside the project bound"); } let mut ids = Vec::with_capacity(claims.len()); - let mut unique = BTreeSet::new(); + let mut claim_versions = BTreeMap::new(); + let mut selected_claims = Vec::with_capacity(claims.len()); for claim in claims { - let id = match claim { - Value::String(id) => id.as_str(), - Value::Object(object) => object - .get("id") - .and_then(Value::as_str) - .ok_or_else(|| anyhow!("live request claim reference is invalid"))?, + let (id, requested_version) = match claim { + Value::String(id) => (id.as_str(), None), + Value::Object(object) => ( + object + .get("id") + .and_then(Value::as_str) + .ok_or_else(|| anyhow!("live request claim reference is invalid"))?, + object.get("version"), + ), _ => bail!("live request claim reference is invalid"), }; - if !services + let service = services .iter() - .any(|service| service.claims.contains_key(id)) - || !unique.insert(id) + .find(|service| service.claims.contains_key(id)) + .ok_or_else(|| anyhow!("live request contains an unknown project claim"))?; + let authored_version = service.version.to_string(); + if requested_version.is_some_and(|version| { + version.as_str() != Some(authored_version.as_str()) + }) { + bail!("live request claim version does not match the authored project"); + } + if claim_versions + .insert(id.to_string(), authored_version) + .is_some() { bail!("live request contains an unknown or duplicate project claim"); } ids.push(id.to_string()); + selected_claims.push( + service + .claims + .get(id) + .expect("selected project claim remains present"), + ); } - Ok(ids) + let disclosure = match object.get("disclosure") { + Some(Value::String(disclosure)) => disclosure.as_str(), + Some(_) => bail!("live request disclosure profile is invalid"), + None => expanded_disclosure(&selected_claims[0].disclosure).0, + }; + if registry_notary_core::DisclosureProfile::parse(disclosure).is_none() { + bail!("live request disclosure profile is invalid"); + } + if selected_claims.iter().any(|claim| { + !expanded_disclosure(&claim.disclosure) + .1 + .contains(&disclosure) + }) { + bail!("live request disclosure is not allowed for every selected project claim"); + } + Ok(ValidatedLiveRequest { + claims: ids, + claim_versions, + notary_service_id, + }) } fn contains_sensitive_request_key(value: &Value) -> bool { diff --git a/crates/registryctl/src/project_authoring/compiler/notary.rs b/crates/registryctl/src/project_authoring/compiler/notary.rs index 343d485d1..f661a8f59 100644 --- a/crates/registryctl/src/project_authoring/compiler/notary.rs +++ b/crates/registryctl/src/project_authoring/compiler/notary.rs @@ -1,5 +1,10 @@ // SPDX-License-Identifier: Apache-2.0 +// Registry project compilation currently authors one evidence subject contract. +// Governed live validation consumes the same constant to reject result +// substitution across subject types. +const AUTHORED_CLAIM_SUBJECT_TYPE: &str = "person"; + fn generated_notary_config( loaded: &LoadedRegistryProject, environment_name: &str, @@ -164,7 +169,7 @@ fn generated_notary_config( "id": claim_id, "title": claim_id.replace('-', " "), "version": service.version.to_string(), - "subject_type": "person", + "subject_type": AUTHORED_CLAIM_SUBJECT_TYPE, "evidence_mode": evidence_mode, "value": { "type": value_type, "nullable": nullable }, "purpose": service.purpose, diff --git a/crates/registryctl/src/project_authoring/fixtures.rs b/crates/registryctl/src/project_authoring/fixtures.rs index 9bcfa67b3..d1d3db28f 100644 --- a/crates/registryctl/src/project_authoring/fixtures.rs +++ b/crates/registryctl/src/project_authoring/fixtures.rs @@ -1737,7 +1737,7 @@ mod fixture_interface_tests { let (_, fixture) = loaded.integrations["health-record"] .fixtures .iter() - .find(|(_, fixture)| fixture.name == "complete-health-match") + .find(|(_, fixture)| fixture.name == "complete-child-health-evidence") .expect("match fixture exists"); let input = offline_fixture_input(fixture).expect("fixture input is valid"); let interactions = diff --git a/crates/registryctl/src/project_authoring/tests.rs b/crates/registryctl/src/project_authoring/tests.rs index 750ec2394..29ad3c644 100644 --- a/crates/registryctl/src/project_authoring/tests.rs +++ b/crates/registryctl/src/project_authoring/tests.rs @@ -656,33 +656,1349 @@ outputs: } } + fn governed_live_claim_result( + claim_id: &str, + value: Value, + satisfied: Option, + disclosure: &str, + ) -> Value { + serde_json::to_value(registry_notary_core::ClaimResultView { + evaluation_id: "eval-live-1".to_string(), + claim_id: claim_id.to_string(), + claim_version: "1.0.0".to_string(), + subject_type: AUTHORED_CLAIM_SUBJECT_TYPE.to_string(), + requester_ref: Some(registry_notary_core::EvidenceEntityRef { + entity_type: "Organisation".to_string(), + handle: format!("rnref:v1:hmac-sha256:{}", "1".repeat(64)), + identifier_schemes: Vec::new(), + profile: None, + }), + target_ref: registry_notary_core::TargetRefView { + entity_type: "Person".to_string(), + handle: format!("rnref:v1:hmac-sha256:{}", "2".repeat(64)), + identifier_schemes: vec!["openspp_individual_id".to_string()], + profile: Some("openspp".to_string()), + }, + value: Some(value), + satisfied, + disclosure: disclosure.to_string(), + redacted_fields: Vec::new(), + format: registry_notary_core::FORMAT_CLAIM_RESULT_JSON.to_string(), + issued_at: "2026-07-23T00:00:00Z".to_string(), + expires_at: None, + provenance: registry_notary_core::ClaimProvenance::new( + "registry-notary".to_string(), + "eval-live-1".to_string(), + claim_id.to_string(), + "1.0.0".to_string(), + registry_notary_core::ProvenanceUsed { + relay_consultation_count: 1, + }, + ), + }) + .expect("actual claim result serializes") + } + + fn governed_live_validated_request(claims: &[&str]) -> ValidatedLiveRequest { + ValidatedLiveRequest { + claims: claims.iter().map(|claim| (*claim).to_string()).collect(), + claim_versions: claims + .iter() + .map(|claim| ((*claim).to_string(), "1.0.0".to_string())) + .collect(), + notary_service_id: "registry-notary".to_string(), + } + } + + fn governed_live_eligible_fixture() -> (ValidatedLiveRequest, Value, Value) { + ( + governed_live_validated_request(&["eligible"]), + json!({ + "claims": { + "eligible": { + "value": true, + "satisfied": true, + "disclosure": "predicate", + }, + }, + }), + json!({ + "results": [governed_live_claim_result( + "eligible", + json!(true), + Some(true), + "predicate", + )], + }), + ) + } + + fn governed_live_validation_window() -> GovernedLiveValidationWindow { + GovernedLiveValidationWindow { + request_started_at: time::macros::datetime!(2026-07-23 0:00 UTC), + response_received_at: time::macros::datetime!(2026-07-23 0:00:01 UTC), + } + } + + fn validate_live_response( + response: &Value, + request: &ValidatedLiveRequest, + expected: &Value, + ) -> Result> { + super::validate_live_response( + response, + request, + expected, + governed_live_validation_window(), + ) + } + + fn set_governed_live_result_pointer(response: &mut Value, pointer: &str, value: Value) { + let result = response + .pointer_mut("/results/0") + .expect("actual claim result exists"); + let (parent_pointer, field) = pointer + .rsplit_once('/') + .expect("result field pointer has a parent"); + let parent = if parent_pointer.is_empty() { + result.as_object_mut() + } else { + result + .pointer_mut(parent_pointer) + .and_then(Value::as_object_mut) + } + .expect("actual result field parent exists"); + parent.insert(field.to_string(), value); + } + #[test] - fn governed_live_result_requires_exact_disclosure_and_source_provenance() { - let claims = vec!["eligible".to_string()]; - let expected = json!({ "claims": { "eligible": { "satisfied": true } } }); + fn governed_live_production_guard_covers_name_and_profile_symmetry() { + for environment in [ + "prod", + "production", + "prod-us", + "production-us", + "us-prod", + "us-production", + "prod_us", + "production.eu", + "eu_prod", + "eu.production", + "owner-prod-copy", + ] { + assert!( + governed_live_environment_is_production( + environment, + Some(DeploymentProfile::Local) + ), + "production-shaped environment name {environment} was accepted" + ); + } + for environment in [ + "owner-pilot", + "preproduction", + "productionish", + "product-copy-us", + "owner-productionish-copy", + "eu_product", + ] { + assert!( + !governed_live_environment_is_production( + environment, + Some(DeploymentProfile::Local) + ), + "non-production-shaped environment name {environment} was rejected" + ); + } + for (profile, rejected) in [ + (DeploymentProfile::Local, false), + (DeploymentProfile::HostedLab, false), + (DeploymentProfile::Production, true), + (DeploymentProfile::EvidenceGrade, true), + ] { + assert_eq!( + governed_live_environment_is_production("owner-pilot", Some(profile)), + rejected, + "unexpected live classification for deployment profile {}", + profile.as_str() + ); + } + } + + #[test] + fn governed_live_result_accepts_actual_shape_and_requires_source_provenance() { + let request = governed_live_validated_request(&["eligible"]); + let expected = json!({ + "claims": { + "eligible": { + "value": true, + "satisfied": true, + "disclosure": "predicate", + }, + }, + }); let response = json!({ - "results": [{ - "claim_id": "eligible", - "satisfied": true, - "provenance": { "used": { "relay_consultation_count": 1 } }, - }], + "results": [governed_live_claim_result( + "eligible", + json!(true), + Some(true), + "predicate", + )], }); assert_eq!( - validate_live_response(&response, &claims, &expected).expect("exact result passes"), - claims + validate_live_response(&response, &request, &expected).expect("exact result passes"), + request.claims ); let mut missing_provenance = response; missing_provenance["results"][0]["provenance"]["used"]["relay_consultation_count"] = json!(0); assert!( - validate_live_response(&missing_provenance, &claims, &expected) + validate_live_response(&missing_provenance, &request, &expected) .expect_err("source-free result must fail") .to_string() .contains("source-backed provenance") ); } + #[test] + fn governed_live_result_requires_explicit_expires_at() { + let request = governed_live_validated_request(&["eligible"]); + let expected = json!({ + "claims": { + "eligible": { + "value": true, + "satisfied": true, + "disclosure": "predicate", + }, + }, + }); + let response = json!({ + "results": [governed_live_claim_result( + "eligible", + json!(true), + Some(true), + "predicate", + )], + }); + + assert_eq!( + response.pointer("/results/0/expires_at"), + Some(&Value::Null) + ); + assert_eq!( + validate_live_response(&response, &request, &expected) + .expect("explicit null expires_at passes"), + request.claims + ); + + let mut missing_expires_at = response; + assert_eq!( + missing_expires_at["results"][0] + .as_object_mut() + .expect("actual result is an object") + .remove("expires_at"), + Some(Value::Null) + ); + assert!( + validate_live_response(&missing_expires_at, &request, &expected) + .expect_err("missing expires_at must fail closed") + .to_string() + .contains("expires_at is required") + ); + } + + #[test] + fn governed_live_result_requires_canonical_provenance_constants() { + let request = governed_live_validated_request(&["eligible"]); + let expected = json!({ + "claims": { + "eligible": { + "value": true, + "satisfied": true, + "disclosure": "predicate", + }, + }, + }); + let response = json!({ + "results": [governed_live_claim_result( + "eligible", + json!(true), + Some(true), + "predicate", + )], + }); + + assert_eq!( + response.pointer("/results/0/provenance/schema_version"), + Some(&json!( + registry_notary_core::CLAIM_PROVENANCE_SCHEMA_VERSION + )) + ); + assert_eq!( + response.pointer("/results/0/provenance/generated_by/type"), + Some(&json!( + registry_notary_core::PROVENANCE_GENERATED_BY_CLAIM_EVALUATION + )) + ); + assert_eq!( + validate_live_response(&response, &request, &expected) + .expect("canonical provenance constants pass"), + request.claims + ); + + for (pointer, value) in [ + ( + "/results/0/provenance/schema_version", + json!("registry-notary-claim-provenance/v1"), + ), + ( + "/results/0/provenance/generated_by/type", + json!("stale_evaluation"), + ), + ] { + let mut invalid = response.clone(); + *invalid + .pointer_mut(pointer) + .expect("actual provenance constant exists") = value; + + assert!( + validate_live_response(&invalid, &request, &expected) + .expect_err("non-canonical provenance constant must fail closed") + .to_string() + .contains("provenance constants"), + "provenance constant {pointer} was accepted" + ); + } + } + + #[test] + fn governed_live_result_binds_provenance_to_returned_result() { + let (request, expected, response) = governed_live_eligible_fixture(); + assert_eq!( + validate_live_response(&response, &request, &expected) + .expect("canonical provenance binding passes"), + request.claims + ); + + for (pointer, value) in [ + ( + "/provenance/generated_by/evaluation_id", + json!("eval-other"), + ), + ("/provenance/generated_by/claim_id", json!("other-claim")), + ( + "/provenance/generated_by/claim_version", + json!("0.9.0"), + ), + ] { + let mut invalid = response.clone(); + set_governed_live_result_pointer(&mut invalid, pointer, value); + + assert!( + validate_live_response(&invalid, &request, &expected) + .expect_err("misbound provenance must fail closed") + .to_string() + .contains("does not identify the returned claim result"), + "provenance binding {pointer} was accepted" + ); + } + } + + #[test] + fn governed_live_result_requires_public_date_time_timestamps() { + let (request, expected, mut response) = governed_live_eligible_fixture(); + set_governed_live_result_pointer( + &mut response, + "/expires_at", + json!("2026-07-24T07:30:00+07:00"), + ); + assert_eq!( + validate_live_response(&response, &request, &expected) + .expect("canonical RFC3339 timestamps pass"), + request.claims + ); + + for pointer in ["/issued_at", "/expires_at"] { + let mut invalid = response.clone(); + set_governed_live_result_pointer(&mut invalid, pointer, json!("not-rfc3339")); + + assert!( + validate_live_response(&invalid, &request, &expected) + .expect_err("invalid public timestamp must fail closed") + .to_string() + .contains("public date-time schema"), + "invalid timestamp {pointer} was accepted" + ); + } + } + + #[test] + fn governed_live_result_timestamps_bind_to_the_current_request_window() { + let (request, expected, response) = governed_live_eligible_fixture(); + let validation_window = GovernedLiveValidationWindow { + request_started_at: time::macros::datetime!(2026-07-23 0:00 UTC), + response_received_at: time::macros::datetime!(2026-07-23 0:00:10 UTC), + }; + + for issued_at in ["2026-07-22T23:59:30Z", "2026-07-23T00:00:40Z"] { + let mut boundary = response.clone(); + set_governed_live_result_pointer(&mut boundary, "/issued_at", json!(issued_at)); + assert_eq!( + super::validate_live_response( + &boundary, + &request, + &expected, + validation_window, + ) + .expect("inclusive remote clock-skew boundary passes"), + request.claims + ); + } + + let mut unexpired = response.clone(); + set_governed_live_result_pointer( + &mut unexpired, + "/expires_at", + json!("2026-07-23T00:00:11Z"), + ); + assert_eq!( + super::validate_live_response(&unexpired, &request, &expected, validation_window) + .expect("expiry strictly after response receipt passes"), + request.claims + ); + + for (issued_at, expires_at) in [ + ("2026-07-22T23:59:29Z", None), + ("2026-07-23T00:00:41Z", None), + ( + "2026-07-23T00:00:00Z", + Some("2026-07-23T00:00:10Z"), + ), + ( + "2026-07-23T00:00:40Z", + Some("2026-07-23T00:00:20Z"), + ), + ( + "2026-07-23T00:00:20Z", + Some("2026-07-23T00:00:20Z"), + ), + ] { + let mut invalid = response.clone(); + set_governed_live_result_pointer(&mut invalid, "/issued_at", json!(issued_at)); + if let Some(expires_at) = expires_at { + set_governed_live_result_pointer( + &mut invalid, + "/expires_at", + json!(expires_at), + ); + } + let error = + super::validate_live_response(&invalid, &request, &expected, validation_window) + .expect_err("stale, future, or expired result must fail closed") + .to_string(); + assert!(error.contains("current live evaluation")); + assert!(!error.contains(issued_at)); + assert!(expires_at.is_none_or(|expires_at| !error.contains(expires_at))); + } + } + + #[test] + fn governed_live_result_accepts_runtime_redaction_evidence() { + let request = governed_live_validated_request(&["household-reference"]); + let expected = json!({ + "claims": { + "household-reference": { + "value": null, + "satisfied": null, + "disclosure": "redacted", + }, + }, + }); + let mut response = json!({ + "results": [governed_live_claim_result( + "household-reference", + Value::Null, + None, + "redacted", + )], + }); + assert!( + response + .pointer("/results/0/redacted_fields") + .is_none(), + "empty redaction metadata is omitted" + ); + set_governed_live_result_pointer( + &mut response, + "/redacted_fields", + json!(["household-reference"]), + ); + + assert_eq!( + validate_live_response(&response, &request, &expected) + .expect("runtime full-redaction evidence passes"), + request.claims + ); + + let mut missing_evidence = response.clone(); + missing_evidence["results"][0] + .as_object_mut() + .expect("actual result is an object") + .remove("redacted_fields"); + assert!( + validate_live_response(&missing_evidence, &request, &expected) + .expect_err("full redaction without redaction evidence must fail closed") + .to_string() + .contains("full-redaction semantics") + ); + + let mut configured_expected = expected.clone(); + configured_expected["claims"]["household-reference"]["redacted_fields"] = + json!(["status", "private_field"]); + let mut configured_response = response.clone(); + set_governed_live_result_pointer( + &mut configured_response, + "/redacted_fields", + json!(["private_field", "status"]), + ); + assert_eq!( + validate_live_response(&configured_response, &request, &configured_expected) + .expect("owner-approved configured redaction fields pass"), + request.claims + ); + + for invalid_markers in [ + json!([]), + json!(["private_field", "private_field"]), + json!(["private_field", "other_claim"]), + json!(["IND-AB12CD34"]), + ] { + let mut invalid = configured_response.clone(); + set_governed_live_result_pointer( + &mut invalid, + "/redacted_fields", + invalid_markers.clone(), + ); + let error = validate_live_response(&invalid, &request, &configured_expected) + .expect_err("runtime redaction fields must match the owner-approved set") + .to_string(); + assert!(error.contains("full-redaction semantics")); + assert!(!error.contains("IND-AB12CD34")); + } + + for invalid_markers in [ + json!([]), + json!([""]), + json!(["household-reference", "household-reference"]), + json!(["other-claim"]), + json!(["IND-AB12CD34"]), + ] { + let mut invalid = response.clone(); + set_governed_live_result_pointer( + &mut invalid, + "/redacted_fields", + invalid_markers.clone(), + ); + let error = validate_live_response(&invalid, &request, &expected) + .expect_err("invalid full-redaction marker set must fail closed") + .to_string(); + assert!(error.contains("full-redaction semantics")); + assert!(!error.contains("IND-AB12CD34")); + } + + for invalid_expected_markers in [ + json!([]), + json!(["private_field", "private_field"]), + json!(["profile.value"]), + json!(["IND-AB12CD34"]), + json!(["ssn=123"]), + ] { + let mut invalid_expected = expected.clone(); + invalid_expected["claims"]["household-reference"]["redacted_fields"] = + invalid_expected_markers.clone(); + let error = validate_live_response(&configured_response, &request, &invalid_expected) + .expect_err("invalid owner redaction fields must fail closed") + .to_string(); + assert!(error.contains("invalid bounded shape")); + assert!(!error.contains("IND-AB12CD34")); + assert!(!error.contains("ssn=123")); + } + } + + #[test] + fn governed_live_result_enforces_value_evidence_semantics() { + let request = governed_live_validated_request(&["programme-code"]); + + for (value, satisfied) in [ + (json!(true), Some(true)), + (json!(false), Some(false)), + (json!("SUPPORT"), None), + (json!(1), None), + (json!({ "status": "eligible" }), None), + (Value::Null, None), + ] { + let expected = json!({ + "claims": { + "programme-code": { + "value": value.clone(), + "satisfied": satisfied, + "disclosure": "value", + }, + }, + }); + let response = json!({ + "results": [governed_live_claim_result( + "programme-code", + value, + satisfied, + "value", + )], + }); + assert_eq!( + validate_live_response(&response, &request, &expected) + .expect("producer-consistent value evidence passes"), + request.claims + ); + } + + for (value, satisfied) in [ + (json!(true), None), + (json!(true), Some(false)), + (json!(false), Some(true)), + (json!("SUPPORT"), Some(false)), + (json!(0), Some(false)), + (json!({ "status": "eligible" }), Some(true)), + (Value::Null, Some(false)), + ] { + let expected = json!({ + "claims": { + "programme-code": { + "value": value.clone(), + "satisfied": satisfied, + "disclosure": "value", + }, + }, + }); + let response = json!({ + "results": [governed_live_claim_result( + "programme-code", + value, + satisfied, + "value", + )], + }); + assert!( + validate_live_response(&response, &request, &expected) + .expect_err("producer-inconsistent copied value fixture must fail closed") + .to_string() + .contains("value evidence semantics") + ); + } + } + + #[test] + fn governed_live_result_enforces_field_redaction_semantics() { + let request = governed_live_validated_request(&["profile"]); + let expected = json!({ + "claims": { + "profile": { + "value": { "status": "eligible" }, + "satisfied": null, + "disclosure": "value", + }, + }, + }); + let mut response = json!({ + "results": [governed_live_claim_result( + "profile", + json!({ "status": "eligible" }), + None, + "value", + )], + }); + set_governed_live_result_pointer( + &mut response, + "/redacted_fields", + json!(["private_field"]), + ); + assert_eq!( + validate_live_response(&response, &request, &expected) + .expect("field-redacted object evidence passes"), + request.claims + ); + + let mut leaked = response.clone(); + leaked["results"][0]["value"]["private_field"] = json!("private source value"); + let error = validate_live_response(&leaked, &request, &expected) + .expect_err("listed redacted field must be absent from the returned value") + .to_string(); + assert!(error.contains("field-redaction semantics")); + assert!(!error.contains("private source value")); + + for (invalid_markers, sensitive_marker) in [ + (json!(["ssn=123"]), "ssn=123"), + (json!(["IND-AB12CD34"]), "IND-AB12CD34"), + (json!(["profile.ssn"]), "profile.ssn"), + (json!(["profile/ssn"]), "profile/ssn"), + ( + json!(["private_field", "private_field"]), + "private_field", + ), + ] { + let mut invalid = response.clone(); + set_governed_live_result_pointer( + &mut invalid, + "/redacted_fields", + invalid_markers, + ); + let error = validate_live_response(&invalid, &request, &expected) + .expect_err("invalid runtime field-redaction marker must fail closed") + .to_string(); + assert!(error.contains("field-redaction semantics")); + assert!(!error.contains(sensitive_marker)); + } + + let excessive_markers = (0..=MAX_OUTPUTS) + .map(|index| format!("private_field_{index}")) + .collect::>(); + let mut excessive = response.clone(); + set_governed_live_result_pointer( + &mut excessive, + "/redacted_fields", + json!(excessive_markers), + ); + let error = validate_live_response(&excessive, &request, &expected) + .expect_err("runtime field-redaction markers must stay bounded") + .to_string(); + assert!(error.contains("field-redaction semantics")); + assert!(!error.contains("private_field_64")); + + let (predicate_request, predicate_expected, mut predicate_response) = + governed_live_eligible_fixture(); + set_governed_live_result_pointer( + &mut predicate_response, + "/redacted_fields", + json!(["private_field"]), + ); + assert!( + validate_live_response( + &predicate_response, + &predicate_request, + &predicate_expected, + ) + .expect_err("predicate over redacted fields must fail closed") + .to_string() + .contains("predicate over redacted fields") + ); + } + + #[test] + fn governed_live_result_enforces_predicate_evidence_semantics() { + let (request, expected, response) = governed_live_eligible_fixture(); + assert_eq!( + validate_live_response(&response, &request, &expected) + .expect("matching boolean predicate evidence passes"), + request.claims + ); + + for invalid_value in [json!({ "status": true }), json!("IND-AB12CD34")] { + let mut invalid = response.clone(); + set_governed_live_result_pointer(&mut invalid, "/value", invalid_value); + let error = validate_live_response(&invalid, &request, &expected) + .expect_err("non-boolean predicate value must fail closed") + .to_string(); + assert!(error.contains("predicate evidence semantics")); + assert!(!error.contains("IND-AB12CD34")); + } + + let mut non_boolean_satisfied = response.clone(); + set_governed_live_result_pointer( + &mut non_boolean_satisfied, + "/satisfied", + json!("IND-AB12CD34"), + ); + let error = validate_live_response(&non_boolean_satisfied, &request, &expected) + .expect_err("non-boolean predicate satisfaction must fail closed") + .to_string(); + assert!(error.contains("closed public claim-result schema")); + assert!(!error.contains("IND-AB12CD34")); + + for (value, satisfied) in [(true, false), (false, true)] { + let mut mismatched = response.clone(); + set_governed_live_result_pointer(&mut mismatched, "/value", json!(value)); + set_governed_live_result_pointer(&mut mismatched, "/satisfied", json!(satisfied)); + assert!( + validate_live_response(&mismatched, &request, &expected) + .expect_err("mismatched predicate booleans must fail closed") + .to_string() + .contains("predicate evidence semantics") + ); + } + } + + #[test] + fn governed_live_response_requires_one_evaluation_id() { + let request = governed_live_validated_request(&["eligible", "active"]); + let expected = json!({ + "claims": { + "eligible": { + "value": true, + "satisfied": true, + "disclosure": "predicate", + }, + "active": { + "value": true, + "satisfied": true, + "disclosure": "predicate", + }, + }, + }); + let response = json!({ + "results": [ + governed_live_claim_result("eligible", json!(true), Some(true), "predicate"), + governed_live_claim_result("active", json!(true), Some(true), "predicate"), + ], + }); + assert_eq!( + validate_live_response(&response, &request, &expected) + .expect("one evaluation id across all results passes"), + request.claim_versions.keys().cloned().collect::>() + ); + + for (pointer, value, sensitive_value) in [ + ( + "/results/1/target_ref/handle", + json!(format!("rnref:v1:hmac-sha256:{}", "3".repeat(64))), + "33333333", + ), + ( + "/results/1/requester_ref/handle", + json!(format!("rnref:v1:hmac-sha256:{}", "4".repeat(64))), + "44444444", + ), + ( + "/results/1/target_ref/profile", + json!("IND-AB12CD34"), + "IND-AB12CD34", + ), + ( + "/results/1/target_ref/identifier_schemes", + json!(["sensitive_scheme"]), + "sensitive_scheme", + ), + ] { + let mut inconsistent = response.clone(); + *inconsistent + .pointer_mut(pointer) + .expect("second result reference field exists") = value; + let error = validate_live_response(&inconsistent, &request, &expected) + .expect_err("inconsistent multi-claim reference must fail closed") + .to_string(); + assert!(error.contains("inconsistent evaluation references")); + assert!(!error.contains(sensitive_value)); + } + + let mut missing_requester = response.clone(); + missing_requester["results"][1] + .as_object_mut() + .expect("second result is an object") + .remove("requester_ref"); + assert!( + validate_live_response(&missing_requester, &request, &expected) + .expect_err("mixed requester presence must fail closed") + .to_string() + .contains("inconsistent evaluation references") + ); + + let mut mixed = response; + mixed["results"][1]["evaluation_id"] = json!("eval-live-2"); + mixed["results"][1]["provenance"]["generated_by"]["evaluation_id"] = + json!("eval-live-2"); + assert!( + validate_live_response(&mixed, &request, &expected) + .expect_err("mixed evaluation ids must fail closed") + .to_string() + .contains("different evaluations") + ); + } + + #[test] + fn governed_live_result_claim_identity_matches_authored_project() { + let loaded = load_registry_project(&project_golden("openspp-exact"), Some("local")) + .expect("OpenSPP golden project loads"); + let request = validate_live_request( + &loaded, + &json!({ + "purpose": "social-programme-verification", + "claims": ["social-registry-record-exists"], + "disclosure": "predicate", + }), + ) + .expect("authored request validates"); + let expected = json!({ + "claims": { + "social-registry-record-exists": { + "value": true, + "satisfied": true, + "disclosure": "predicate", + }, + }, + }); + let mut response = json!({ + "results": [governed_live_claim_result( + "social-registry-record-exists", + json!(true), + Some(true), + "predicate", + )], + }); + response["results"][0]["claim_version"] = json!("1"); + response["results"][0]["provenance"]["generated_by"]["claim_version"] = json!("1"); + response["results"][0]["provenance"]["generated_by"]["service_id"] = + json!("social-registry-notary"); + assert_eq!( + validate_live_response(&response, &request, &expected) + .expect("authored claim version passes"), + request.claims + ); + + let mut wrong_version = response; + wrong_version["results"][0]["claim_version"] = json!("2.0.0"); + wrong_version["results"][0]["provenance"]["generated_by"]["claim_version"] = + json!("2.0.0"); + assert!( + validate_live_response(&wrong_version, &request, &expected) + .expect_err("result from a different claim version must fail closed") + .to_string() + .contains("does not match the authored project") + ); + + let mut wrong_subject_type = wrong_version; + wrong_subject_type["results"][0]["claim_version"] = json!("1"); + wrong_subject_type["results"][0]["provenance"]["generated_by"]["claim_version"] = json!("1"); + wrong_subject_type["results"][0]["subject_type"] = json!("organisation"); + assert!( + validate_live_response(&wrong_subject_type, &request, &expected) + .expect_err("result for a different claim subject type must fail closed") + .to_string() + .contains("subject type does not match the authored project") + ); + } + + #[test] + fn governed_live_result_service_matches_selected_environment() { + let loaded = load_registry_project(&project_golden("openspp-exact"), Some("local")) + .expect("OpenSPP golden project and environment load"); + let request = validate_live_request( + &loaded, + &json!({ + "purpose": "social-programme-verification", + "claims": ["social-registry-record-exists"], + "disclosure": "predicate", + }), + ) + .expect("authored request validates"); + assert_eq!(request.notary_service_id, "social-registry-notary"); + let expected = json!({ + "claims": { + "social-registry-record-exists": { + "value": true, + "satisfied": true, + "disclosure": "predicate", + }, + }, + }); + let mut response = json!({ + "results": [governed_live_claim_result( + "social-registry-record-exists", + json!(true), + Some(true), + "predicate", + )], + }); + response["results"][0]["claim_version"] = json!("1"); + response["results"][0]["provenance"]["generated_by"]["claim_version"] = json!("1"); + response["results"][0]["provenance"]["generated_by"]["service_id"] = + json!("social-registry-notary"); + assert_eq!( + validate_live_response(&response, &request, &expected) + .expect("selected Notary service passes"), + request.claims + ); + + response["results"][0]["provenance"]["generated_by"]["service_id"] = + json!("stale-notary"); + let error = validate_live_response(&response, &request, &expected) + .expect_err("wrong Notary service must fail closed") + .to_string(); + assert!(error.contains("does not identify the selected Notary service")); + assert!(!error.contains("stale-notary")); + } + + #[test] + fn governed_live_result_requires_notary_pseudonymous_reference_handles() { + let (request, expected, response) = governed_live_eligible_fixture(); + assert_eq!( + validate_live_response(&response, &request, &expected) + .expect("keyed Notary pseudonymous references pass"), + request.claims + ); + + let mut local = response.clone(); + for pointer in ["/target_ref/handle", "/requester_ref/handle"] { + set_governed_live_result_pointer( + &mut local, + pointer, + json!(format!("rnref:v1:sha256:{}", "a".repeat(64))), + ); + } + assert_eq!( + validate_live_response(&local, &request, &expected) + .expect("local-development Notary pseudonymous references pass"), + request.claims + ); + + for pointer in ["/target_ref/handle", "/requester_ref/handle"] { + for invalid_handle in [ + "person-123".to_string(), + "rnref:v1:hmac-sha256:abcd".to_string(), + format!("rnref:v1:hmac-sha256:{}", "A".repeat(64)), + format!("rnref:v1:sha512:{}", "a".repeat(64)), + "rnref:v1:IND-AB12CD34".to_string(), + ] { + let mut invalid = response.clone(); + set_governed_live_result_pointer( + &mut invalid, + pointer, + json!(invalid_handle.clone()), + ); + let error = validate_live_response(&invalid, &request, &expected) + .expect_err("invalid Notary reference handle must fail closed") + .to_string(); + assert!(error.contains("invalid pseudonymous reference handle")); + assert!(!error.contains("IND-AB12CD34")); + } + } + } + + #[test] + fn governed_live_result_rejects_null_optional_public_fields_recursively() { + let (request, expected, mut response) = governed_live_eligible_fixture(); + set_governed_live_result_pointer(&mut response, "/redacted_fields", json!([])); + for (pointer, value) in [ + ("/requester_ref/identifier_schemes", json!([])), + ("/requester_ref/profile", json!("requester")), + ] { + set_governed_live_result_pointer(&mut response, pointer, value); + } + assert_eq!( + validate_live_response(&response, &request, &expected) + .expect("canonical optional public fields pass"), + request.claims + ); + + for pointer in LIVE_RESULT_OPTIONAL_NON_NULL_PATHS { + let mut invalid = response.clone(); + set_governed_live_result_pointer(&mut invalid, pointer, Value::Null); + + assert!( + validate_live_response(&invalid, &request, &expected) + .expect_err("null optional public field must fail closed") + .to_string() + .contains("optional public field cannot be null"), + "null optional public field {pointer} was accepted" + ); + } + } + + #[test] + fn governed_live_api_key_result_rejects_named_policy_provenance() { + let (request, expected, response) = governed_live_eligible_fixture(); + for field in ["policy_id", "policy_version", "policy_hash"] { + assert!( + response + .pointer(&format!( + "/results/0/provenance/generated_by/{field}" + )) + .is_none(), + "machine-client runtime fixture unexpectedly carries {field}" + ); + } + assert_eq!( + validate_live_response(&response, &request, &expected) + .expect("API-key result without named policy provenance passes"), + request.claims + ); + + for (pointer, value, sensitive_value) in [ + ( + "/provenance/generated_by/policy_id", + json!("IND-AB12CD34"), + "IND-AB12CD34", + ), + ( + "/provenance/generated_by/policy_version", + json!("secret-policy-v1"), + "secret-policy-v1", + ), + ( + "/provenance/generated_by/policy_hash", + json!("sha256:sensitive-policy-hash"), + "sha256:sensitive-policy-hash", + ), + ] { + let mut invalid = response.clone(); + set_governed_live_result_pointer(&mut invalid, pointer, value); + let error = validate_live_response(&invalid, &request, &expected) + .expect_err("API-key result with named policy provenance must fail closed") + .to_string(); + assert!(error.contains("unexpected named policy provenance")); + assert!(!error.contains(sensitive_value)); + } + } + + #[test] + fn governed_live_result_requires_claim_result_format() { + let (request, expected, mut response) = governed_live_eligible_fixture(); + assert_eq!( + response.pointer("/results/0/format"), + Some(&json!(registry_notary_core::FORMAT_CLAIM_RESULT_JSON)) + ); + assert_eq!( + validate_live_response(&response, &request, &expected) + .expect("canonical claim-result format passes"), + request.claims + ); + + set_governed_live_result_pointer(&mut response, "/format", json!("application/json")); + assert!( + validate_live_response(&response, &request, &expected) + .expect_err("wrong result format must fail closed") + .to_string() + .contains("invalid claim-result format") + ); + } + + #[test] + fn governed_live_result_rejects_partial_disclosure_expectations() { + let request = governed_live_validated_request(&["eligible"]); + let expected = json!({ + "claims": { + "eligible": { + "satisfied": true, + "disclosure": "predicate", + }, + }, + }); + let response = json!({ + "results": [governed_live_claim_result( + "eligible", + json!("unexpected source value"), + Some(true), + "predicate", + )], + }); + + assert!( + validate_live_response(&response, &request, &expected) + .expect_err("partial expected disclosure must fail closed") + .to_string() + .contains("must contain value, satisfied, disclosure") + ); + } + + #[test] + fn governed_live_result_rejects_unknown_result_fields() { + let request = governed_live_validated_request(&["eligible"]); + let expected = json!({ + "claims": { + "eligible": { + "value": true, + "satisfied": true, + "disclosure": "predicate", + }, + }, + }); + let mut response = json!({ + "results": [governed_live_claim_result( + "eligible", + json!(true), + Some(true), + "predicate", + )], + }); + response["results"][0]["raw_value"] = json!("unexpected source value"); + + assert!( + validate_live_response(&response, &request, &expected) + .expect_err("unknown result fields must fail closed") + .to_string() + .contains("closed public claim-result schema") + ); + } + + #[test] + fn governed_live_result_rejects_nested_unknown_fields() { + let request = governed_live_validated_request(&["eligible"]); + let expected = json!({ + "claims": { + "eligible": { + "value": true, + "satisfied": true, + "disclosure": "predicate", + }, + }, + }); + let response = json!({ + "results": [governed_live_claim_result( + "eligible", + json!(true), + Some(true), + "predicate", + )], + }); + + for (pointer, field) in [ + ("/results/0/target_ref", "raw_identifier"), + ("/results/0/requester_ref", "raw_principal"), + ("/results/0/provenance", "raw_source"), + ("/results/0/provenance/generated_by", "raw_policy"), + ("/results/0/provenance/used", "raw_source_count"), + ] { + let mut over_disclosed = response.clone(); + over_disclosed + .pointer_mut(pointer) + .and_then(Value::as_object_mut) + .expect("actual nested result object exists") + .insert(field.to_string(), json!("private")); + + assert!( + validate_live_response(&over_disclosed, &request, &expected) + .expect_err("nested unknown fields must fail closed") + .to_string() + .contains("closed public claim-result schema"), + "nested field {pointer}/{field} was accepted" + ); + } + } + + #[test] + fn governed_live_result_requires_empty_derived_from() { + let request = governed_live_validated_request(&["eligible"]); + let expected = json!({ + "claims": { + "eligible": { + "value": true, + "satisfied": true, + "disclosure": "predicate", + }, + }, + }); + let mut response = json!({ + "results": [governed_live_claim_result( + "eligible", + json!(true), + Some(true), + "predicate", + )], + }); + response["results"][0]["provenance"]["derived_from"] = + json!([{ "raw_source_row": "private" }]); + + assert!( + validate_live_response(&response, &request, &expected) + .expect_err("non-empty derived_from must fail closed") + .to_string() + .contains("derived_from must remain empty") + ); + } + + #[test] + fn governed_live_result_rejects_provenance_fields_outside_public_schema() { + let request = governed_live_validated_request(&["eligible"]); + let expected = json!({ + "claims": { + "eligible": { + "value": true, + "satisfied": true, + "disclosure": "predicate", + }, + }, + }); + let response = json!({ + "results": [governed_live_claim_result( + "eligible", + json!(true), + Some(true), + "predicate", + )], + }); + + for field in ["pack_id", "pack_version"] { + for value in [json!("unsupported"), Value::Null] { + let mut unsupported = response.clone(); + unsupported["results"][0]["provenance"]["generated_by"][field] = value; + + assert!( + validate_live_response(&unsupported, &request, &expected) + .expect_err("fields outside the public schema must fail closed") + .to_string() + .contains("exceeds the closed public claim-result schema"), + "provenance field {field} was accepted" + ); + } + } + } + + #[test] + fn governed_live_request_negotiates_the_claim_result_media_type() { + let endpoint = + url::Url::parse("http://127.0.0.1:8080/v1/evaluations").expect("endpoint parses"); + let request = governed_live_evaluation_request(&endpoint, "test-api-key"); + + assert_eq!(request.header("content-type"), Some("application/json")); + assert_eq!( + request.header("accept"), + Some(registry_notary_core::FORMAT_CLAIM_RESULT_JSON) + ); + } + + #[test] + fn governed_live_report_does_not_infer_a_source_outcome() { + let request = governed_live_validated_request(&["record-exists"]); + let expected = json!({ + "claims": { + "record-exists": { + "value": false, + "satisfied": false, + "disclosure": "predicate", + }, + }, + }); + let response = json!({ + "results": [governed_live_claim_result( + "record-exists", + json!(false), + Some(false), + "predicate", + )], + }); + let returned_claims = + validate_live_response(&response, &request, &expected).expect("no-match result passes"); + let report = governed_live_fixture_report(returned_claims); + + assert_eq!(report.claims, request.claims); + assert_eq!(report.outcome, None); + assert!( + serde_json::to_value(report) + .expect("report serializes") + .get("outcome") + .is_none(), + "neutral live evidence must not serialize a source outcome" + ); + } + #[test] fn cel_consultation_roots_ignore_string_literals() { assert_eq!( @@ -730,10 +2046,85 @@ outputs: )); } + #[test] + fn live_request_requires_one_compatible_disclosure_profile() { + let loaded = load_registry_project(&project_golden("openspp-exact"), Some("local")) + .expect("OpenSPP golden project loads"); + + for (disclosure, claims) in [ + ( + "predicate", + vec!["social-registry-record-exists", "social-registry-active"], + ), + ("value", vec!["programme-code"]), + ("redacted", vec!["household-reference"]), + ] { + let request = json!({ + "purpose": "social-programme-verification", + "claims": claims, + "disclosure": disclosure, + }); + let validated = validate_live_request(&loaded, &request) + .expect("compatible disclosure request passes"); + assert!(validated + .claim_versions + .values() + .all(|version| version == "1")); + } + + let mixed = json!({ + "purpose": "social-programme-verification", + "claims": [ + "social-registry-record-exists", + "programme-code", + "household-reference", + ], + }); + assert!( + validate_live_request(&loaded, &mixed) + .expect_err("mixed defaults must fail before source access") + .to_string() + .contains("not allowed for every selected project claim") + ); + } + + #[test] + fn live_request_claim_version_must_match_the_authored_service() { + let loaded = load_registry_project(&project_golden("openspp-exact"), Some("local")) + .expect("OpenSPP golden project loads"); + let request = |version| { + json!({ + "purpose": "social-programme-verification", + "claims": [{ + "id": "social-registry-record-exists", + "version": version, + }], + "disclosure": "predicate", + }) + }; + + let validated = + validate_live_request(&loaded, &request("1")).expect("authored version passes"); + assert_eq!( + validated.claim_versions, + BTreeMap::from([( + "social-registry-record-exists".to_string(), + "1".to_string(), + )]) + ); + assert!( + validate_live_request(&loaded, &request("2")) + .expect_err("non-authored request version must fail closed") + .to_string() + .contains("does not match the authored project") + ); + } + #[test] fn live_request_resolves_claims_across_services_with_the_same_purpose() { let project = project_golden("custom-system"); - let mut loaded = load_registry_project(&project, None).expect("golden project loads"); + let mut loaded = + load_registry_project(&project, Some("local")).expect("golden project loads"); let original_id = "household-eligibility"; let mut second: ServiceDeclaration = serde_json::from_value( serde_json::to_value(&loaded.project.services[original_id]) @@ -778,15 +2169,29 @@ outputs: .expect("split service project compiles"); validate_generated_notary(&compiled).expect("split service Notary config activates"); - let claims = validate_live_request( + let request = validate_live_request( &loaded, &json!({ "purpose": "household-support-screening", - "claims": ["household-category", "household-eligible"], + "claims": ["household-category", "source-household-approval-decision"], + "disclosure": "redacted", }), ) .expect("claims from both same-purpose services are valid"); - assert_eq!(claims, ["household-category", "household-eligible"]); + assert_eq!( + request.claims, + ["household-category", "source-household-approval-decision"] + ); + assert_eq!( + request.claim_versions, + BTreeMap::from([ + ("household-category".to_string(), "1".to_string()), + ( + "source-household-approval-decision".to_string(), + "1".to_string(), + ), + ]) + ); } #[test] diff --git a/crates/registryctl/tests/exit_status.rs b/crates/registryctl/tests/exit_status.rs new file mode 100644 index 000000000..aef3e9084 --- /dev/null +++ b/crates/registryctl/tests/exit_status.rs @@ -0,0 +1,57 @@ +use std::process::Command; + +fn run_registryctl(args: &[&str]) -> std::process::Output { + Command::new(env!("CARGO_BIN_EXE_registryctl")) + .args(args) + .env("REGISTRYCTL_NO_UPDATE_CHECK", "1") + .output() + .expect("registryctl runs") +} + +#[test] +fn success_uses_exit_status_zero() { + let output = run_registryctl(&["--version"]); + + assert_eq!(output.status.code(), Some(0)); +} + +#[test] +fn command_failure_uses_exit_status_one() { + let temporary = tempfile::tempdir().expect("temporary directory"); + let output = Command::new(env!("CARGO_BIN_EXE_registryctl")) + .arg("restart") + .current_dir(temporary.path()) + .env("REGISTRYCTL_NO_UPDATE_CHECK", "1") + .output() + .expect("registryctl runs"); + + assert_eq!(output.status.code(), Some(1)); +} + +#[test] +fn usage_failure_uses_exit_status_two() { + let output = run_registryctl(&["not-a-command"]); + + assert_eq!(output.status.code(), Some(2)); +} + +#[test] +fn removed_spreadsheet_alias_is_a_usage_failure() { + let output = run_registryctl(&["init", "spreadsheet-api", "project"]); + + assert_eq!(output.status.code(), Some(2)); +} + +#[test] +fn missing_init_mode_is_a_usage_failure() { + let output = run_registryctl(&["init"]); + + assert_eq!(output.status.code(), Some(2)); +} + +#[test] +fn conflicting_init_modes_are_a_usage_failure() { + let output = run_registryctl(&["init", "--from", "http", "relay", "project"]); + + assert_eq!(output.status.code(), Some(2)); +} diff --git a/crates/registryctl/tests/fixtures/project-authoring-journeys.yaml b/crates/registryctl/tests/fixtures/project-authoring-journeys.yaml index 1ed14ab04..ace5ca33a 100644 --- a/crates/registryctl/tests/fixtures/project-authoring-journeys.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring-journeys.yaml @@ -20,7 +20,7 @@ workspaces: classification: maintained topology: combined project_dir: crates/registryctl/tests/fixtures/project-authoring/custom-system - focused_fixture_file: eligible.yaml + focused_fixture_file: source-approved.yaml steps: [trace, watch, test, check, build] environment: local check_explain: true @@ -38,7 +38,7 @@ workspaces: - id: dhis2-tracker label: DHIS2 Tracker - summary: A product-neutral script adapter applied to a bounded DHIS2 Tracker read journey. + summary: Bounded DHIS2 Tracker acquisition normalized into reusable health evidence for consumer-owned decisions. source: crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker classification: maintained topology: combined diff --git a/crates/registryctl/tests/fixtures/project-authoring/custom-system/integrations/eligibility/fixtures/no-match.yaml b/crates/registryctl/tests/fixtures/project-authoring/custom-system/integrations/eligibility/fixtures/no-match.yaml index d6cb7475f..bb62583e9 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/custom-system/integrations/eligibility/fixtures/no-match.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/custom-system/integrations/eligibility/fixtures/no-match.yaml @@ -10,4 +10,7 @@ interactions: expect: outcome: no_match outputs: {} - claims: { household-record-exists: false, household-category: null, household-eligible: false } + claims: + household-record-exists: false + household-category: null + source-household-approval-decision: null diff --git a/crates/registryctl/tests/fixtures/project-authoring/custom-system/integrations/eligibility/fixtures/eligible.yaml b/crates/registryctl/tests/fixtures/project-authoring/custom-system/integrations/eligibility/fixtures/source-approved.yaml similarity index 69% rename from crates/registryctl/tests/fixtures/project-authoring/custom-system/integrations/eligibility/fixtures/eligible.yaml rename to crates/registryctl/tests/fixtures/project-authoring/custom-system/integrations/eligibility/fixtures/source-approved.yaml index 765baa92a..64343f01d 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/custom-system/integrations/eligibility/fixtures/eligible.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/custom-system/integrations/eligibility/fixtures/source-approved.yaml @@ -1,4 +1,4 @@ -name: eligible-household +name: source-approved-household classification: synthetic input: { household_reference: HH-AB12CD34 } interactions: @@ -12,4 +12,7 @@ interactions: expect: outcome: match outputs: { approved: true, category: PRIORITY } - claims: { household-record-exists: true, household-category: PRIORITY, household-eligible: true } + claims: + household-record-exists: true + household-category: PRIORITY + source-household-approval-decision: true diff --git a/crates/registryctl/tests/fixtures/project-authoring/custom-system/integrations/eligibility/integration.yaml b/crates/registryctl/tests/fixtures/project-authoring/custom-system/integrations/eligibility/integration.yaml index 731c2b8bb..509061824 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/custom-system/integrations/eligibility/integration.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/custom-system/integrations/eligibility/integration.yaml @@ -38,4 +38,4 @@ outputs: not_applicable: subject_mismatch: rationale: The selected response projection contains no identifier that can be compared with the requested household reference. - request_fixture: eligible-household + request_fixture: source-approved-household diff --git a/crates/registryctl/tests/fixtures/project-authoring/custom-system/registry-stack.yaml b/crates/registryctl/tests/fixtures/project-authoring/custom-system/registry-stack.yaml index a27bad844..21f03bd9b 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/custom-system/registry-stack.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/custom-system/registry-stack.yaml @@ -28,13 +28,13 @@ services: household-category: output: household.category disclosure: value - household-eligible: + source-household-approval-decision: cel: >- - household.approved != null ? household.matched && household.approved : false + household.matched && household.approved != null ? household.approved : null disclosure: predicate credential_profiles: household-eligibility: format: dc+sd-jwt type: https://credentials.invalid/household-eligibility/v1 validity: 5m - claims: [household-record-exists, household-category, household-eligible] + claims: [household-record-exists, household-category, source-household-approval-decision] diff --git a/crates/registryctl/tests/fixtures/project-authoring/dhis2-script/integrations/health-record/adapter.rhai b/crates/registryctl/tests/fixtures/project-authoring/dhis2-script/integrations/health-record/adapter.rhai index 2c8ae0310..e7db83777 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/dhis2-script/integrations/health-record/adapter.rhai +++ b/crates/registryctl/tests/fixtures/project-authoring/dhis2-script/integrations/health-record/adapter.rhai @@ -17,16 +17,27 @@ fn enrollment_for(enrollments, programme_uid) { () } -fn completed_stage(enrollment, stage_uid) { +fn enrollment_active(enrollment) { if enrollment == () { - return false; + return (); } + enrollment.status == "ACTIVE" +} + +fn completed_stage_evidence(enrollment, stage_uid) { + if enrollment == () { + return (); + } + let stage_exists = false; for event in enrollment.events { - if event.programStage == stage_uid && event.status == "COMPLETED" { - return true; + if event.programStage == stage_uid { + if event.status == "COMPLETED" { + return true; + } + stage_exists = true; } } - false + if stage_exists { false } else { () } } fn consult(ctx) { @@ -34,6 +45,9 @@ fn consult(ctx) { let maternal_program_uid = "DEMO_MATERNAL_PROGRAM_UID"; let tb_program_uid = "DEMO_TB_PROGRAM_UID"; let child_visit_stage_uid = "DEMO_CHILD_VISIT_STAGE_UID"; + let bcg_birth_stage_uid = "DEMO_BCG_BIRTH_STAGE_UID"; + let opv_birth_stage_uid = "DEMO_OPV_BIRTH_STAGE_UID"; + let measles_stage_uid = "DEMO_MEASLES_STAGE_UID"; let first_name_uid = "DEMO_FIRST_NAME_ATTRIBUTE_UID"; let last_name_uid = "DEMO_LAST_NAME_ATTRIBUTE_UID"; let birth_date_uid = "DEMO_BIRTH_DATE_ATTRIBUTE_UID"; @@ -68,11 +82,14 @@ fn consult(ctx) { first_name: attribute_value(entity.attributes, first_name_uid), last_name: attribute_value(entity.attributes, last_name_uid), date_of_birth: attribute_value(entity.attributes, birth_date_uid), - child_program_active: child != () && child.status == "ACTIVE", + child_program_active: enrollment_active(child), programme_code: if child == () { () } else { "DEMO_CHILD_PROGRAM" }, reconciliation_reference: attribute_value(entity.attributes, reconciliation_uid), - maternal_postnatal_active: maternal != () && maternal.status == "ACTIVE", - child_health_visit_recorded: completed_stage(child, child_visit_stage_uid), - tb_program_active: tb != () && tb.status == "ACTIVE" + maternal_postnatal_active: enrollment_active(maternal), + child_health_visit_recorded: completed_stage_evidence(child, child_visit_stage_uid), + tb_program_active: enrollment_active(tb), + bcg_birth_dose_recorded: completed_stage_evidence(child, bcg_birth_stage_uid), + opv_birth_dose_recorded: completed_stage_evidence(child, opv_birth_stage_uid), + measles_dose_recorded: completed_stage_evidence(child, measles_stage_uid) }) } diff --git a/crates/registryctl/tests/fixtures/project-authoring/dhis2-script/integrations/health-record/fixtures/match.yaml b/crates/registryctl/tests/fixtures/project-authoring/dhis2-script/integrations/health-record/fixtures/match.yaml index 1f3cf20ea..fe6107235 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/dhis2-script/integrations/health-record/fixtures/match.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/dhis2-script/integrations/health-record/fixtures/match.yaml @@ -1,4 +1,4 @@ -name: complete-health-match +name: complete-child-health-evidence classification: synthetic input: { tracked_entity: A0000000001, include_inactive: true } variables: { as_of_date: 2026-01-01 } @@ -21,7 +21,11 @@ interactions: enrollments: - program: DEMO_CHILD_PROGRAM_UID status: ACTIVE - events: [{ programStage: DEMO_CHILD_VISIT_STAGE_UID, status: COMPLETED }] + events: + - { programStage: DEMO_CHILD_VISIT_STAGE_UID, status: COMPLETED } + - { programStage: DEMO_BCG_BIRTH_STAGE_UID, status: COMPLETED } + - { programStage: DEMO_OPV_BIRTH_STAGE_UID, status: COMPLETED } + - { programStage: DEMO_MEASLES_STAGE_UID, status: COMPLETED } - { program: DEMO_MATERNAL_PROGRAM_UID, status: ACTIVE, events: [] } - { program: DEMO_TB_PROGRAM_UID, status: CANCELLED, events: [] } ignored_upstream_field: safe-extra-field @@ -37,6 +41,9 @@ expect: maternal_postnatal_active: true child_health_visit_recorded: true tb_program_active: false + bcg_birth_dose_recorded: true + opv_birth_dose_recorded: true + measles_dose_recorded: true claims: tracked-entity-first-name: Nia tracked-entity-last-name: Example @@ -47,3 +54,6 @@ expect: maternal-postnatal-care-active: true child-health-visit-recorded: true tb-program-active: false + bcg-birth-dose-recorded: true + opv-birth-dose-recorded: true + measles-dose-recorded: true diff --git a/crates/registryctl/tests/fixtures/project-authoring/dhis2-script/integrations/health-record/fixtures/no-enrollment.yaml b/crates/registryctl/tests/fixtures/project-authoring/dhis2-script/integrations/health-record/fixtures/no-enrollment.yaml new file mode 100644 index 000000000..62f8bf449 --- /dev/null +++ b/crates/registryctl/tests/fixtures/project-authoring/dhis2-script/integrations/health-record/fixtures/no-enrollment.yaml @@ -0,0 +1,46 @@ +name: no-child-program-enrollment +classification: synthetic +input: { tracked_entity: A0000000001, include_inactive: true } +variables: { as_of_date: 2026-01-01 } +interactions: + - expect: + method: GET + path: /api/tracker/trackedEntities/A0000000001 + query: + fields: trackedEntity,attributes[attribute,value],enrollments[program,status,events[programStage,status]] + includeDeleted: true + respond: + status: 200 + body: + trackedEntity: A0000000001 + attributes: [] + enrollments: + - { program: DEMO_OTHER_PROGRAM_UID, status: ACTIVE, events: [] } +expect: + outcome: match + outputs: + first_name: null + last_name: null + date_of_birth: null + child_program_active: null + programme_code: null + reconciliation_reference: null + maternal_postnatal_active: null + child_health_visit_recorded: null + tb_program_active: null + bcg_birth_dose_recorded: null + opv_birth_dose_recorded: null + measles_dose_recorded: null + claims: + tracked-entity-first-name: null + tracked-entity-last-name: null + child-program-active: null + child-age-band: null + programme-code: null + reconciliation-reference: redacted + maternal-postnatal-care-active: null + child-health-visit-recorded: null + tb-program-active: null + bcg-birth-dose-recorded: null + opv-birth-dose-recorded: null + measles-dose-recorded: null diff --git a/crates/registryctl/tests/fixtures/project-authoring/dhis2-script/integrations/health-record/fixtures/no-match.yaml b/crates/registryctl/tests/fixtures/project-authoring/dhis2-script/integrations/health-record/fixtures/no-match.yaml index 1fecb6434..ded4cda16 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/dhis2-script/integrations/health-record/fixtures/no-match.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/dhis2-script/integrations/health-record/fixtures/no-match.yaml @@ -16,10 +16,13 @@ expect: claims: tracked-entity-first-name: null tracked-entity-last-name: null - child-program-active: false + child-program-active: null child-age-band: null programme-code: null reconciliation-reference: redacted - maternal-postnatal-care-active: false - child-health-visit-recorded: false - tb-program-active: false + maternal-postnatal-care-active: null + child-health-visit-recorded: null + tb-program-active: null + bcg-birth-dose-recorded: null + opv-birth-dose-recorded: null + measles-dose-recorded: null diff --git a/crates/registryctl/tests/fixtures/project-authoring/dhis2-script/integrations/health-record/fixtures/partial.yaml b/crates/registryctl/tests/fixtures/project-authoring/dhis2-script/integrations/health-record/fixtures/partial.yaml new file mode 100644 index 000000000..006970494 --- /dev/null +++ b/crates/registryctl/tests/fixtures/project-authoring/dhis2-script/integrations/health-record/fixtures/partial.yaml @@ -0,0 +1,50 @@ +name: partial-child-health-evidence +classification: synthetic +input: { tracked_entity: A0000000001, include_inactive: true } +variables: { as_of_date: 2026-01-01 } +interactions: + - expect: + method: GET + path: /api/tracker/trackedEntities/A0000000001 + query: + fields: trackedEntity,attributes[attribute,value],enrollments[program,status,events[programStage,status]] + includeDeleted: true + respond: + status: 200 + body: + trackedEntity: A0000000001 + attributes: [] + enrollments: + - program: DEMO_CHILD_PROGRAM_UID + status: CANCELLED + events: + - { programStage: DEMO_BCG_BIRTH_STAGE_UID, status: SKIPPED } + - { programStage: DEMO_MEASLES_STAGE_UID, status: COMPLETED } +expect: + outcome: match + outputs: + first_name: null + last_name: null + date_of_birth: null + child_program_active: false + programme_code: DEMO_CHILD_PROGRAM + reconciliation_reference: null + maternal_postnatal_active: null + child_health_visit_recorded: null + tb_program_active: null + bcg_birth_dose_recorded: false + opv_birth_dose_recorded: null + measles_dose_recorded: true + claims: + tracked-entity-first-name: null + tracked-entity-last-name: null + child-program-active: false + child-age-band: null + programme-code: DEMO_CHILD_PROGRAM + reconciliation-reference: redacted + maternal-postnatal-care-active: null + child-health-visit-recorded: null + tb-program-active: null + bcg-birth-dose-recorded: false + opv-birth-dose-recorded: null + measles-dose-recorded: true diff --git a/crates/registryctl/tests/fixtures/project-authoring/dhis2-script/integrations/health-record/integration.yaml b/crates/registryctl/tests/fixtures/project-authoring/dhis2-script/integrations/health-record/integration.yaml index afbe4a642..375eed5bb 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/dhis2-script/integrations/health-record/integration.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/dhis2-script/integrations/health-record/integration.yaml @@ -29,16 +29,19 @@ outputs: first_name: { type: [string, "null"], maxLength: 80 } last_name: { type: [string, "null"], maxLength: 80 } date_of_birth: { type: [string, "null"], format: date, maxLength: 10 } - child_program_active: { type: boolean } + child_program_active: { type: [boolean, "null"] } programme_code: { type: [string, "null"], maxLength: 32 } reconciliation_reference: { type: [string, "null"], maxLength: 64 } - maternal_postnatal_active: { type: boolean } - child_health_visit_recorded: { type: boolean } - tb_program_active: { type: boolean } + maternal_postnatal_active: { type: [boolean, "null"] } + child_health_visit_recorded: { type: [boolean, "null"] } + tb_program_active: { type: [boolean, "null"] } + bcg_birth_dose_recorded: { type: [boolean, "null"] } + opv_birth_dose_recorded: { type: [boolean, "null"] } + measles_dose_recorded: { type: [boolean, "null"] } not_applicable: ambiguity: rationale: The tracked-entity identifier is resolved by the exact singleton trackedEntities resource endpoint, so a collection cannot be returned. - request_fixture: complete-health-match + request_fixture: complete-child-health-evidence limits: { calls: 1, source_bytes: 64KiB, request_bytes: 4KiB, deadline: 8s } diff --git a/crates/registryctl/tests/fixtures/project-authoring/dhis2-script/registry-stack.yaml b/crates/registryctl/tests/fixtures/project-authoring/dhis2-script/registry-stack.yaml index 22a37bad4..7496cebe9 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/dhis2-script/registry-stack.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/dhis2-script/registry-stack.yaml @@ -21,12 +21,7 @@ services: claims: tracked-entity-first-name: { output: health.first_name, disclosure: value } tracked-entity-last-name: { output: health.last_name, disclosure: value } - child-program-active: - cel: >- - health.child_program_active != null - ? health.matched && health.child_program_active - : false - disclosure: predicate + child-program-active: { output: health.child_program_active, disclosure: predicate } child-age-band: cel: >- health.matched && health.date_of_birth != null @@ -37,24 +32,12 @@ services: disclosure: value programme-code: { output: health.programme_code, disclosure: value } reconciliation-reference: { output: health.reconciliation_reference, disclosure: redacted } - maternal-postnatal-care-active: - cel: >- - health.maternal_postnatal_active != null - ? health.matched && health.maternal_postnatal_active - : false - disclosure: predicate - child-health-visit-recorded: - cel: >- - health.child_health_visit_recorded != null - ? health.matched && health.child_health_visit_recorded - : false - disclosure: predicate - tb-program-active: - cel: >- - health.tb_program_active != null - ? health.matched && health.tb_program_active - : false - disclosure: predicate + maternal-postnatal-care-active: { output: health.maternal_postnatal_active, disclosure: predicate } + child-health-visit-recorded: { output: health.child_health_visit_recorded, disclosure: predicate } + tb-program-active: { output: health.tb_program_active, disclosure: predicate } + bcg-birth-dose-recorded: { output: health.bcg_birth_dose_recorded, disclosure: predicate } + opv-birth-dose-recorded: { output: health.opv_birth_dose_recorded, disclosure: predicate } + measles-dose-recorded: { output: health.measles_dose_recorded, disclosure: predicate } credential_profiles: health-status: format: dc+sd-jwt @@ -71,3 +54,8 @@ services: type: https://credentials.invalid/programme-participation/v1 validity: 10m claims: [programme-code, reconciliation-reference, child-program-active] + child-health-evidence: + format: dc+sd-jwt + type: https://credentials.invalid/child-health-evidence/v1 + validity: 10m + claims: [child-program-active, bcg-birth-dose-recorded, opv-birth-dose-recorded, measles-dose-recorded] diff --git a/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/README.md b/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/README.md index f4b8d785e..8f880d548 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/README.md +++ b/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/README.md @@ -1,13 +1,14 @@ -# Script source-adapter Registry Stack project +# DHIS2 child-health evidence Registry Stack project This starter demonstrates the product-neutral `script` capability with a synthetic DHIS2 Tracker wire shape. Product and version metadata do not select -the Rhai runtime. +the Rhai runtime. The offline fixtures are the deterministic acceptance path; +a reachable live DHIS2 instance is optional compatibility evidence. ```bash registryctl authoring editor --project-dir . -registryctl test --project-dir . --integration health-record --fixture complete-health-match --trace -registryctl test --project-dir . --integration health-record --fixture complete-health-match --watch +registryctl test --project-dir . --integration health-record --fixture complete-child-health-evidence --trace +registryctl test --project-dir . --integration health-record --fixture complete-child-health-evidence --watch registryctl test --project-dir . registryctl check --project-dir . --environment local --explain registryctl build --project-dir . --environment local @@ -21,6 +22,35 @@ from this `registryctl` build for VS Code and Zed. Edit the reviewed `adapter.rhai`, integration contract, and synthetic fixtures together. Keep source credentials in the environment binding. +The authored layers remain separate: + +1. Relay performs the bounded DHIS2 request and preserves the starter's + declared identity, date, programme, reconciliation, and health-status + outputs. Nullable programme and stage booleans keep `true`, `false`, and + `null` distinct, including BCG, OPV, and measles dose evidence. +2. Notary discloses those outputs as atomic evidence claims. It does not decide + outreach, follow-up priority, eligibility, entitlement, or case action. +3. In this example, a public-health programme is both the evidence consumer and + decision owner. It might first route any `null` evidence to resolution, then + derive `outreach_required` only from known enrollment and dose evidence. + That downstream rule is illustrative and is not part of this Registry Stack + project. + +For a matched tracked entity, a completed DHIS2 programme-stage event maps to +`true`, an existing non-completed stage event maps to `false`, and an absent +enrollment or stage maps to `null`. A 404 is a no-match, not negative health +evidence. An upstream rejection and an echoed-subject mismatch are failures +and produce no claims. Ambiguity is explicitly not applicable because the +adapter uses DHIS2's singleton tracked-entity resource. + +The demo programme and stage UIDs in `adapter.rhai` are project-owned mappings. +Replace and review them against the deployed DHIS2 metadata without changing +the product-neutral Script runtime or broadening source access. + +Record any live compatibility result through the repository root's +`release/conformance/integrations/` evidence flow. Never rewrite the +deterministic offline fixtures to reflect a transient live server result. + The `include_inactive` boolean is a bounded, typed target attribute supplied by the evaluation caller and forwarded through Notary and Relay. It is request context only. It is not an authenticated identity or a substitute for the diff --git a/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/integrations/health-record/adapter.rhai b/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/integrations/health-record/adapter.rhai index 2c8ae0310..e7db83777 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/integrations/health-record/adapter.rhai +++ b/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/integrations/health-record/adapter.rhai @@ -17,16 +17,27 @@ fn enrollment_for(enrollments, programme_uid) { () } -fn completed_stage(enrollment, stage_uid) { +fn enrollment_active(enrollment) { if enrollment == () { - return false; + return (); } + enrollment.status == "ACTIVE" +} + +fn completed_stage_evidence(enrollment, stage_uid) { + if enrollment == () { + return (); + } + let stage_exists = false; for event in enrollment.events { - if event.programStage == stage_uid && event.status == "COMPLETED" { - return true; + if event.programStage == stage_uid { + if event.status == "COMPLETED" { + return true; + } + stage_exists = true; } } - false + if stage_exists { false } else { () } } fn consult(ctx) { @@ -34,6 +45,9 @@ fn consult(ctx) { let maternal_program_uid = "DEMO_MATERNAL_PROGRAM_UID"; let tb_program_uid = "DEMO_TB_PROGRAM_UID"; let child_visit_stage_uid = "DEMO_CHILD_VISIT_STAGE_UID"; + let bcg_birth_stage_uid = "DEMO_BCG_BIRTH_STAGE_UID"; + let opv_birth_stage_uid = "DEMO_OPV_BIRTH_STAGE_UID"; + let measles_stage_uid = "DEMO_MEASLES_STAGE_UID"; let first_name_uid = "DEMO_FIRST_NAME_ATTRIBUTE_UID"; let last_name_uid = "DEMO_LAST_NAME_ATTRIBUTE_UID"; let birth_date_uid = "DEMO_BIRTH_DATE_ATTRIBUTE_UID"; @@ -68,11 +82,14 @@ fn consult(ctx) { first_name: attribute_value(entity.attributes, first_name_uid), last_name: attribute_value(entity.attributes, last_name_uid), date_of_birth: attribute_value(entity.attributes, birth_date_uid), - child_program_active: child != () && child.status == "ACTIVE", + child_program_active: enrollment_active(child), programme_code: if child == () { () } else { "DEMO_CHILD_PROGRAM" }, reconciliation_reference: attribute_value(entity.attributes, reconciliation_uid), - maternal_postnatal_active: maternal != () && maternal.status == "ACTIVE", - child_health_visit_recorded: completed_stage(child, child_visit_stage_uid), - tb_program_active: tb != () && tb.status == "ACTIVE" + maternal_postnatal_active: enrollment_active(maternal), + child_health_visit_recorded: completed_stage_evidence(child, child_visit_stage_uid), + tb_program_active: enrollment_active(tb), + bcg_birth_dose_recorded: completed_stage_evidence(child, bcg_birth_stage_uid), + opv_birth_dose_recorded: completed_stage_evidence(child, opv_birth_stage_uid), + measles_dose_recorded: completed_stage_evidence(child, measles_stage_uid) }) } diff --git a/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/integrations/health-record/fixtures/match.yaml b/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/integrations/health-record/fixtures/match.yaml index 1f3cf20ea..fe6107235 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/integrations/health-record/fixtures/match.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/integrations/health-record/fixtures/match.yaml @@ -1,4 +1,4 @@ -name: complete-health-match +name: complete-child-health-evidence classification: synthetic input: { tracked_entity: A0000000001, include_inactive: true } variables: { as_of_date: 2026-01-01 } @@ -21,7 +21,11 @@ interactions: enrollments: - program: DEMO_CHILD_PROGRAM_UID status: ACTIVE - events: [{ programStage: DEMO_CHILD_VISIT_STAGE_UID, status: COMPLETED }] + events: + - { programStage: DEMO_CHILD_VISIT_STAGE_UID, status: COMPLETED } + - { programStage: DEMO_BCG_BIRTH_STAGE_UID, status: COMPLETED } + - { programStage: DEMO_OPV_BIRTH_STAGE_UID, status: COMPLETED } + - { programStage: DEMO_MEASLES_STAGE_UID, status: COMPLETED } - { program: DEMO_MATERNAL_PROGRAM_UID, status: ACTIVE, events: [] } - { program: DEMO_TB_PROGRAM_UID, status: CANCELLED, events: [] } ignored_upstream_field: safe-extra-field @@ -37,6 +41,9 @@ expect: maternal_postnatal_active: true child_health_visit_recorded: true tb_program_active: false + bcg_birth_dose_recorded: true + opv_birth_dose_recorded: true + measles_dose_recorded: true claims: tracked-entity-first-name: Nia tracked-entity-last-name: Example @@ -47,3 +54,6 @@ expect: maternal-postnatal-care-active: true child-health-visit-recorded: true tb-program-active: false + bcg-birth-dose-recorded: true + opv-birth-dose-recorded: true + measles-dose-recorded: true diff --git a/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/integrations/health-record/fixtures/no-enrollment.yaml b/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/integrations/health-record/fixtures/no-enrollment.yaml new file mode 100644 index 000000000..62f8bf449 --- /dev/null +++ b/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/integrations/health-record/fixtures/no-enrollment.yaml @@ -0,0 +1,46 @@ +name: no-child-program-enrollment +classification: synthetic +input: { tracked_entity: A0000000001, include_inactive: true } +variables: { as_of_date: 2026-01-01 } +interactions: + - expect: + method: GET + path: /api/tracker/trackedEntities/A0000000001 + query: + fields: trackedEntity,attributes[attribute,value],enrollments[program,status,events[programStage,status]] + includeDeleted: true + respond: + status: 200 + body: + trackedEntity: A0000000001 + attributes: [] + enrollments: + - { program: DEMO_OTHER_PROGRAM_UID, status: ACTIVE, events: [] } +expect: + outcome: match + outputs: + first_name: null + last_name: null + date_of_birth: null + child_program_active: null + programme_code: null + reconciliation_reference: null + maternal_postnatal_active: null + child_health_visit_recorded: null + tb_program_active: null + bcg_birth_dose_recorded: null + opv_birth_dose_recorded: null + measles_dose_recorded: null + claims: + tracked-entity-first-name: null + tracked-entity-last-name: null + child-program-active: null + child-age-band: null + programme-code: null + reconciliation-reference: redacted + maternal-postnatal-care-active: null + child-health-visit-recorded: null + tb-program-active: null + bcg-birth-dose-recorded: null + opv-birth-dose-recorded: null + measles-dose-recorded: null diff --git a/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/integrations/health-record/fixtures/no-match.yaml b/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/integrations/health-record/fixtures/no-match.yaml index 1fecb6434..ded4cda16 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/integrations/health-record/fixtures/no-match.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/integrations/health-record/fixtures/no-match.yaml @@ -16,10 +16,13 @@ expect: claims: tracked-entity-first-name: null tracked-entity-last-name: null - child-program-active: false + child-program-active: null child-age-band: null programme-code: null reconciliation-reference: redacted - maternal-postnatal-care-active: false - child-health-visit-recorded: false - tb-program-active: false + maternal-postnatal-care-active: null + child-health-visit-recorded: null + tb-program-active: null + bcg-birth-dose-recorded: null + opv-birth-dose-recorded: null + measles-dose-recorded: null diff --git a/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/integrations/health-record/fixtures/partial.yaml b/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/integrations/health-record/fixtures/partial.yaml new file mode 100644 index 000000000..006970494 --- /dev/null +++ b/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/integrations/health-record/fixtures/partial.yaml @@ -0,0 +1,50 @@ +name: partial-child-health-evidence +classification: synthetic +input: { tracked_entity: A0000000001, include_inactive: true } +variables: { as_of_date: 2026-01-01 } +interactions: + - expect: + method: GET + path: /api/tracker/trackedEntities/A0000000001 + query: + fields: trackedEntity,attributes[attribute,value],enrollments[program,status,events[programStage,status]] + includeDeleted: true + respond: + status: 200 + body: + trackedEntity: A0000000001 + attributes: [] + enrollments: + - program: DEMO_CHILD_PROGRAM_UID + status: CANCELLED + events: + - { programStage: DEMO_BCG_BIRTH_STAGE_UID, status: SKIPPED } + - { programStage: DEMO_MEASLES_STAGE_UID, status: COMPLETED } +expect: + outcome: match + outputs: + first_name: null + last_name: null + date_of_birth: null + child_program_active: false + programme_code: DEMO_CHILD_PROGRAM + reconciliation_reference: null + maternal_postnatal_active: null + child_health_visit_recorded: null + tb_program_active: null + bcg_birth_dose_recorded: false + opv_birth_dose_recorded: null + measles_dose_recorded: true + claims: + tracked-entity-first-name: null + tracked-entity-last-name: null + child-program-active: false + child-age-band: null + programme-code: DEMO_CHILD_PROGRAM + reconciliation-reference: redacted + maternal-postnatal-care-active: null + child-health-visit-recorded: null + tb-program-active: null + bcg-birth-dose-recorded: false + opv-birth-dose-recorded: null + measles-dose-recorded: true diff --git a/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/integrations/health-record/integration.yaml b/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/integrations/health-record/integration.yaml index afbe4a642..375eed5bb 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/integrations/health-record/integration.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/integrations/health-record/integration.yaml @@ -29,16 +29,19 @@ outputs: first_name: { type: [string, "null"], maxLength: 80 } last_name: { type: [string, "null"], maxLength: 80 } date_of_birth: { type: [string, "null"], format: date, maxLength: 10 } - child_program_active: { type: boolean } + child_program_active: { type: [boolean, "null"] } programme_code: { type: [string, "null"], maxLength: 32 } reconciliation_reference: { type: [string, "null"], maxLength: 64 } - maternal_postnatal_active: { type: boolean } - child_health_visit_recorded: { type: boolean } - tb_program_active: { type: boolean } + maternal_postnatal_active: { type: [boolean, "null"] } + child_health_visit_recorded: { type: [boolean, "null"] } + tb_program_active: { type: [boolean, "null"] } + bcg_birth_dose_recorded: { type: [boolean, "null"] } + opv_birth_dose_recorded: { type: [boolean, "null"] } + measles_dose_recorded: { type: [boolean, "null"] } not_applicable: ambiguity: rationale: The tracked-entity identifier is resolved by the exact singleton trackedEntities resource endpoint, so a collection cannot be returned. - request_fixture: complete-health-match + request_fixture: complete-child-health-evidence limits: { calls: 1, source_bytes: 64KiB, request_bytes: 4KiB, deadline: 8s } diff --git a/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/registry-stack.yaml b/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/registry-stack.yaml index 9dc685bc9..a610b06e9 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/registry-stack.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/registry-stack.yaml @@ -2,7 +2,7 @@ version: 1 starter: id: dhis2-tracker release: 0.12.2 - content_digest: sha256:406356fab5814d45f8cce64d85840031da0a87288778572e392e218dea0886f7 + content_digest: sha256:0f1f5fb710c5490d92346120d0cd791bac4bbd617033f03b9402ba3174caff19 registry: { id: fictional-health-registry } integrations: health-record: { file: integrations/health-record/integration.yaml } @@ -25,12 +25,7 @@ services: claims: tracked-entity-first-name: { output: health.first_name, disclosure: value } tracked-entity-last-name: { output: health.last_name, disclosure: value } - child-program-active: - cel: >- - health.child_program_active != null - ? health.matched && health.child_program_active - : false - disclosure: predicate + child-program-active: { output: health.child_program_active, disclosure: predicate } child-age-band: cel: >- health.matched && health.date_of_birth != null @@ -41,24 +36,12 @@ services: disclosure: value programme-code: { output: health.programme_code, disclosure: value } reconciliation-reference: { output: health.reconciliation_reference, disclosure: redacted } - maternal-postnatal-care-active: - cel: >- - health.maternal_postnatal_active != null - ? health.matched && health.maternal_postnatal_active - : false - disclosure: predicate - child-health-visit-recorded: - cel: >- - health.child_health_visit_recorded != null - ? health.matched && health.child_health_visit_recorded - : false - disclosure: predicate - tb-program-active: - cel: >- - health.tb_program_active != null - ? health.matched && health.tb_program_active - : false - disclosure: predicate + maternal-postnatal-care-active: { output: health.maternal_postnatal_active, disclosure: predicate } + child-health-visit-recorded: { output: health.child_health_visit_recorded, disclosure: predicate } + tb-program-active: { output: health.tb_program_active, disclosure: predicate } + bcg-birth-dose-recorded: { output: health.bcg_birth_dose_recorded, disclosure: predicate } + opv-birth-dose-recorded: { output: health.opv_birth_dose_recorded, disclosure: predicate } + measles-dose-recorded: { output: health.measles_dose_recorded, disclosure: predicate } credential_profiles: health-status: format: dc+sd-jwt @@ -75,3 +58,8 @@ services: type: https://credentials.invalid/programme-participation/v1 validity: 10m claims: [programme-code, reconciliation-reference, child-program-active] + child-health-evidence: + format: dc+sd-jwt + type: https://credentials.invalid/child-health-evidence/v1 + validity: 10m + claims: [child-program-active, bcg-birth-dose-recorded, opv-birth-dose-recorded, measles-dose-recorded] diff --git a/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md b/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md new file mode 100644 index 000000000..578fdc65d --- /dev/null +++ b/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md @@ -0,0 +1,314 @@ +# OpenSPP exact-lookup owner holdout + +This workspace is offline-fixture-only pending the OpenSPP product-owner proof +in [GH#357](https://github.com/registrystack/registry-stack/issues/357). +It is not a supported public starter, evidence of live OpenSPP compatibility, +or an E2 interoperability claim. + +The committed HTTP path, query, fields, version label, identifiers, and +responses describe a synthetic wire shape. +They do not assert that an OpenSPP release exposes this API. + +## Run the offline workspace + +Run these commands from the repository root: + +```sh +cd crates/registryctl/tests/fixtures/project-authoring/openspp-exact +registryctl test --project-dir . --integration individual --fixture social-registry-match --trace +registryctl test --project-dir . --integration individual --fixture social-registry-match --watch +registryctl test --project-dir . +registryctl check --project-dir . --environment local --explain +registryctl build --project-dir . --environment local +``` + +The watch command runs the selected fixture once, waits for an authored file to +change, and reruns it. +Press `Ctrl+C` after observing the rerun. +The other commands exit after reporting their result. + +`test` uses synthetic fixtures and an implementation-owned offline binding. +It does not contact the origin in `environments/local.yaml`. +`check` and `build` validate that environment, but the `.invalid` origins and +placeholder secret references are deliberately non-deployable. + +## Prepare the owner-run workspace + +Use a private copy of this workspace from one tagged Registry Stack release +that contains both +[PR #355](https://github.com/registrystack/registry-stack/pull/355) and +[PR #364](https://github.com/registrystack/registry-stack/pull/364). +Record the tag and use the matching `registryctl`, Relay image, Notary image, +and published image digests. +Atomically pin the candidate adopter deployment to those artifacts. +Do not use a source build, a branch image, the retired monorepo `lab/`, shared +Relay or Notary state, or a hand-edited generated YAML file. +Each Relay authority needs its own dedicated Notary and Notary-owned PostgreSQL +state. + +Select and record one exact OpenSPP version and one read-only operation before +editing the workspace. +Replace every fictional assumption in the private copy: + +| File | Fields to replace and review | +| --- | --- | +| `integrations/individual/integration.yaml` | Replace `id` and the `source.versions.unverified` fixture label with the reviewed integration identity and exact selected OpenSPP version. Classify that version under `source.versions.tested` only after the owner evidence is accepted. | +| `integrations/individual/integration.yaml` | Replace the input name, type, length, pattern, HTTP method, relative path, query, no-match statuses, authentication type, response format, and response-size bound with the selected read-only operation's contract. | +| `integrations/individual/integration.yaml` | Replace output names, types, lengths, and `x-registry-source` pointers. Reassess the `ambiguity` and `subject_mismatch` not-applicable rationales. Add fixtures when either outcome is applicable. | +| `integrations/individual/fixtures/*.yaml` | Replace the synthetic selector, request expectation, source response, normalized outputs, outcome, and claims. Keep all retained fixture data synthetic. | +| `registry-stack.yaml` | Replace `registry.id`, the service id, `purpose`, `legal_basis`, `consent`, `access.scopes`, the consultation input mapping, claim ids and declarations, disclosure modes, and credential profile with reviewed owner decisions. | +| `environments/.yaml` | Replace `integrations.individual.source.origin`, `integrations.individual.source.credential.token.secret`, and `integrations.individual.source.credential.generation` with the private OpenSPP source binding. | +| `environments/.yaml` | Replace every `issuance` field and the `callers.programme-service` map key, API-key fingerprint secret reference, and scopes with candidate-owned values. | +| `environments/.yaml` | Replace every `relay`, `notary_relay`, and `deployment` field. Add the candidate-required state bindings. Keep all deployment values outside public evidence. | + +Use bounded one-request HTTP authoring when it expresses the selected operation. +Use reviewed Rhai only when the operation requires project-owned traversal or +normalization. +Do not add OpenSPP-specific Rust dispatch, restore a Notary source connector, +add an integration sidecar, or edit generated runtime configuration. + +Rerun the focused trace, complete offline fixture suite, check, and build after +every contract change. +For the private owner environment, replace `local` in the check and build +commands with the exact environment filename without `.yaml`. +Activate the generated Relay and Notary Config Bundle inputs through the +documented path for the selected tagged candidate, without modifying generated +files. + +## Prepare the governed live evaluation + +Create the request and expected-result files under `.registry-stack/` in the +private workspace. +That directory is ignored by this workspace. +Use only an owner-approved non-production record. +Notary applies one disclosure profile to the whole evaluation, and this +project's generated disclosure policies deny incompatible downgrades. +Run separate request and expected-result pairs for the predicate, value, and +redacted claims. +The following pairs match the committed synthetic project, so replace the +identifier scheme, value, purpose, claim ids, and expected values when the +authored project changes. + +Predicate request: + +```json +{ + "target": { + "type": "Person", + "identifiers": [ + { + "scheme": "openspp_individual_id", + "value": "IND-AB12CD34" + } + ] + }, + "claims": [ + "social-registry-record-exists", + "social-registry-active" + ], + "disclosure": "predicate", + "format": "application/vnd.registry-notary.claim-result+json", + "purpose": "social-programme-verification" +} +``` + +Predicate expected result: + +```json +{ + "claims": { + "social-registry-record-exists": { + "value": true, + "satisfied": true, + "disclosure": "predicate" + }, + "social-registry-active": { + "value": true, + "satisfied": true, + "disclosure": "predicate" + } + } +} +``` + +Value request: + +```json +{ + "target": { + "type": "Person", + "identifiers": [ + { + "scheme": "openspp_individual_id", + "value": "IND-AB12CD34" + } + ] + }, + "claims": ["programme-code"], + "disclosure": "value", + "format": "application/vnd.registry-notary.claim-result+json", + "purpose": "social-programme-verification" +} +``` + +Value expected result: + +```json +{ + "claims": { + "programme-code": { + "value": "SUPPORT", + "satisfied": null, + "disclosure": "value" + } + } +} +``` + +Redacted request: + +```json +{ + "target": { + "type": "Person", + "identifiers": [ + { + "scheme": "openspp_individual_id", + "value": "IND-AB12CD34" + } + ] + }, + "claims": ["household-reference"], + "disclosure": "redacted", + "format": "application/vnd.registry-notary.claim-result+json", + "purpose": "social-programme-verification" +} +``` + +Redacted expected result: + +```json +{ + "claims": { + "household-reference": { + "value": null, + "satisfied": null, + "disclosure": "redacted" + } + } +} +``` + +`registryctl` sends +`Accept: application/vnd.registry-notary.claim-result+json`, matching the +request's `format`, and validates that claim-result envelope. + +Each expected-result file must contain only a `claims` object. +Its keys must exactly match the corresponding request's claim ids. +Each claim value must contain exactly `value`, `satisfied`, and `disclosure`, +using `null` when the Notary result has no value or satisfaction decision. +For a fully redacted claim whose deployed policy configures top-level redaction +fields, add `redacted_fields` containing that exact owner-approved field set. +The set must contain one to 64 unique bounded top-level field names; order in +the expected file is not significant. Omit `redacted_fields` when the policy +does not configure a field set, in which case the runner requires Notary's +default single claim-id marker. +The live runner rejects keys outside its accepted result, reference, and +provenance structures, including `pack_id` and `pack_version`; validates +public `redacted_fields` against either that owner-approved canonical field set +or the default claim-id marker, without exposing a listed field value; requires +`target_ref` and optional `requester_ref` handles to use the Notary `rnref:v1` +pseudonymous SHA-256 shape; requires every result in the evaluation to carry +the same complete target and optional requester references; rejects nulls for +optional public fields; requires one evaluation id and the canonical +claim-result format; requires RFC3339 timestamps and binds `issued_at` to the +current POST and response-read window with a 30-second remote-clock allowance +in either direction; requires a non-null `expires_at` to remain strictly after +both response receipt and issuance while accepting the schema's explicit null; +rejects named-policy provenance on this API-key flow; binds each provenance +record and claim version to the returned result and authored project; requires +exact `value`, `satisfied`, and `disclosure` matches; requires an empty +`derived_from` array; and requires a non-zero Relay consultation count. +The runner validates the public pseudonym shape but cannot recompute its keyed +digest from the private owner record. +These examples reflect only the committed synthetic fixture and must be +replaced with reviewed expectations for the owner-approved record. + +The governed live test reads exactly four process variables: + +| Variable | Owner-supplied value | +| --- | --- | +| `REGISTRY_STACK_LIVE_NOTARY_ORIGIN` | The non-production candidate Notary origin. Use HTTPS with no path, query, user information, or fragment. HTTP is accepted only for a loopback origin. | +| `REGISTRY_STACK_LIVE_NOTARY_API_KEY` | The deployed Notary caller API key. This is not the OpenSPP source credential. Load it from the owner's secret mechanism, never from a command argument or tracked file. | +| `REGISTRY_STACK_LIVE_REQUEST_FILE` | The absolute path to the strict JSON evaluation request. Use a bounded regular file, not a symbolic link. | +| `REGISTRY_STACK_LIVE_EXPECTED_FILE` | The absolute path to the strict JSON expected-result file. Use a bounded regular file, not a symbolic link. | + +Load the API key into the process environment without echoing it, export the +other three variables, and run: + +```sh +registryctl test --project-dir . --environment --live +``` + +The command refuses environment names containing an exact `prod` or +`production` segment separated by `.`, `_`, or `-`, plus environments whose +`deployment.profile` is `production` or `evidence_grade`. +This guard classifies the selected environment name and profile; it does not +infer deployment classification from the operator-supplied Notary origin. +It first reruns the offline fixtures, checks the candidate Notary's Relay +readiness, and then sends one request to the governed Notary evaluation path. +It requires the returned claim fields to match the expected file and requires +source-backed Relay provenance. +It never sends the request directly to OpenSPP. +Unset all four variables after the run. + +Repeat the live command with separate private request and expected-result files +for each owner-approved match or no-match case. +Use the candidate's governed denial and failure checks for authorization denial +before source access and bounded source failures. +Prove ambiguity and subject mismatch with live cases when the selected operation +makes them applicable, or retain an owner-reviewed not-applicable rationale +that matches the real response contract. + +## Evidence and redaction checklist + +Before asking to close GH#357, record: + +- [ ] Exact OpenSPP version and read-only operation, including which + country-specific mapping files changed. +- [ ] Exact Registry Stack tag, `registryctl` version, adopter commit, Relay and + Notary image digests, and per-authority Notary and PostgreSQL topology. +- [ ] The commands and pass or fail outcomes for focused trace, watch, complete + offline fixtures, check, build, activation, and governed live evaluation. +- [ ] Match and no-match outcomes, plus applicable ambiguity or subject-mismatch + behavior or reviewed reasons that they are not applicable. +- [ ] Authorization denial before source access, bounded failure, disclosure, + redaction, and source-backed provenance outcomes. +- [ ] Confirmation that generated Relay and Notary files were activated + unchanged. +- [ ] Confirmation that no OpenSPP-specific Registry Stack Rust, Notary source + connector, integration sidecar, or direct registry test path was needed. +- [ ] Confirmation that changing the country mapping required only reviewed + project-authored files and fixtures. +- [ ] Any gap fixed in the generic authoring or runtime model, or recorded as an + explicit limitation before the 1.0 decision. + +Before retaining or publishing evidence: + +- [ ] Remove Notary and OpenSPP credentials, secret values, private origins, + private network details, raw selectors, subject identifiers, source rows, + source response bodies, and deployment-specific file paths. +- [ ] Do not retain shell history, environment dumps, packet captures, verbose + HTTP transcripts, or logs that can contain those values. +- [ ] Keep public evidence to tested versions, the operation description, + commands, outcomes, limitations, redaction checks, and non-sensitive + artifact digests. +- [ ] Have the OpenSPP owner review the redacted evidence before publication. + +A successful owner run can support only the wording +`live-authoring-validated OpenSPP starter` for the exact tested version and +operation. +It does not establish E2 interoperability unless the complete common +integration matrix also passes. +Until that evidence exists, this workspace remains an offline fixture and no +public OpenSPP support claim follows from it. diff --git a/crates/registryctl/tests/fixtures/project-authoring/snapshot-exact/README.md b/crates/registryctl/tests/fixtures/project-authoring/snapshot-exact/README.md index ce4b7c562..e20d333f6 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/snapshot-exact/README.md +++ b/crates/registryctl/tests/fixtures/project-authoring/snapshot-exact/README.md @@ -18,3 +18,12 @@ from this `registryctl` build for VS Code and Zed. Add a records service only when the project intentionally publishes the entity through Relay's governed records API. + +Relay normalizes the source fields as `registration_status` and +`residency_confirmed`. Notary exposes the reusable +`population-registration-status` and `residency-confirmed` evidence claims. +The evidence consumer, not this project, determines how those claims are used. +The decision owner remains accountable for eligibility, qualification, +prioritization, approval, payment, workflow, and action rules. A no-match keeps +both evidence values unknown rather than silently converting missing evidence +to a negative fact. diff --git a/crates/registryctl/tests/fixtures/project-authoring/snapshot-exact/entities/people.yaml b/crates/registryctl/tests/fixtures/project-authoring/snapshot-exact/entities/people.yaml index 0d049f5f6..652de0085 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/snapshot-exact/entities/people.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/snapshot-exact/entities/people.yaml @@ -5,11 +5,11 @@ primary_key: person_id schema: type: object additionalProperties: false - required: [person_id, registration_status, eligible, guardian_id] + required: [person_id, registration_status, residency_confirmed, guardian_id] properties: person_id: { type: string, maxLength: 64 } registration_status: { type: [string, "null"], maxLength: 32 } - eligible: { type: [boolean, "null"] } + residency_confirmed: { type: [boolean, "null"] } guardian_id: { type: [string, "null"], maxLength: 64 } materialization: max_records: 1000000 diff --git a/crates/registryctl/tests/fixtures/project-authoring/snapshot-exact/environments/local.yaml b/crates/registryctl/tests/fixtures/project-authoring/snapshot-exact/environments/local.yaml index 09cf1a3dd..ff8f18ee5 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/snapshot-exact/environments/local.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/snapshot-exact/environments/local.yaml @@ -8,7 +8,7 @@ entities: columns: person_id: subject_key registration_status: status_code - eligible: benefit_eligible + residency_confirmed: residency_flag guardian_id: guardian_key source_revision: population-export-v1 generation: 2026-07-12 diff --git a/crates/registryctl/tests/fixtures/project-authoring/snapshot-exact/integrations/person-snapshot/fixtures/match.yaml b/crates/registryctl/tests/fixtures/project-authoring/snapshot-exact/integrations/person-snapshot/fixtures/match.yaml index f3ae1d58c..bbe7cc7d4 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/snapshot-exact/integrations/person-snapshot/fixtures/match.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/snapshot-exact/integrations/person-snapshot/fixtures/match.yaml @@ -3,13 +3,13 @@ classification: synthetic input: { person_id: PER-00000001 } interactions: - expect: { method: GET, path: /snapshot } - respond: { status: 200, body: { registration_status: active, eligible: true } } + respond: { status: 200, body: { registration_status: active, residency_confirmed: true } } expect: outcome: match - outputs: { registration_status: active, eligible: true } + outputs: { registration_status: active, residency_confirmed: true } claims: population-record-exists: true - benefits-status: active - benefits-eligible: true + population-registration-status: active + residency-confirmed: true emergency-record-exists: true emergency-status: redacted diff --git a/crates/registryctl/tests/fixtures/project-authoring/snapshot-exact/integrations/person-snapshot/fixtures/no-match.yaml b/crates/registryctl/tests/fixtures/project-authoring/snapshot-exact/integrations/person-snapshot/fixtures/no-match.yaml index 972dad2b4..55a1e18a6 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/snapshot-exact/integrations/person-snapshot/fixtures/no-match.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/snapshot-exact/integrations/person-snapshot/fixtures/no-match.yaml @@ -9,7 +9,7 @@ expect: outputs: {} claims: population-record-exists: false - benefits-status: null - benefits-eligible: null + population-registration-status: null + residency-confirmed: null emergency-record-exists: false emergency-status: redacted diff --git a/crates/registryctl/tests/fixtures/project-authoring/snapshot-exact/integrations/person-snapshot/integration.yaml b/crates/registryctl/tests/fixtures/project-authoring/snapshot-exact/integrations/person-snapshot/integration.yaml index 4baa2fd06..5dbae4f8e 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/snapshot-exact/integrations/person-snapshot/integration.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/snapshot-exact/integrations/person-snapshot/integration.yaml @@ -13,7 +13,7 @@ capability: exact: person_id: { input: person_id } freshness: 24h -outputs: [registration_status, eligible] +outputs: [registration_status, residency_confirmed] not_applicable: ambiguity: rationale: The exact snapshot selector is the entity primary key, whose materialized unique-key constraint permits at most one record. diff --git a/crates/registryctl/tests/fixtures/project-authoring/snapshot-exact/registry-stack.yaml b/crates/registryctl/tests/fixtures/project-authoring/snapshot-exact/registry-stack.yaml index 78f6c7741..92efb42a5 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/snapshot-exact/registry-stack.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/snapshot-exact/registry-stack.yaml @@ -2,7 +2,7 @@ version: 1 starter: id: snapshot release: 0.12.2 - content_digest: sha256:efcf38e246e52ad93ca5d0f716386f8657080afe133a63782d1892f802ee630f + content_digest: sha256:fddfd7203247c69ada8449b456bd4878e3d760061f4b99be23919e63d61237fb registry: { id: fictional-population-registry } integrations: person-snapshot: { file: integrations/person-snapshot/integration.yaml } @@ -22,14 +22,14 @@ services: input: { person_id: request.target.identifiers.population_person_id } claims: population-record-exists: { cel: person.matched, disclosure: predicate } - benefits-status: { output: person.registration_status, disclosure: value } - benefits-eligible: { output: person.eligible, disclosure: predicate } + population-registration-status: { output: person.registration_status, disclosure: value } + residency-confirmed: { output: person.residency_confirmed, disclosure: predicate } credential_profiles: - benefits-status: + population-evidence: format: dc+sd-jwt - type: https://credentials.invalid/benefits-status/v1 + type: https://credentials.invalid/population-evidence/v1 validity: 10m - claims: [population-record-exists, benefits-status, benefits-eligible] + claims: [population-record-exists, population-registration-status, residency-confirmed] emergency-assistance: kind: evidence version: 1 diff --git a/crates/registryctl/tests/fixtures/project-authoring/snapshot-with-records/entities/people.yaml b/crates/registryctl/tests/fixtures/project-authoring/snapshot-with-records/entities/people.yaml index 0d049f5f6..652de0085 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/snapshot-with-records/entities/people.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/snapshot-with-records/entities/people.yaml @@ -5,11 +5,11 @@ primary_key: person_id schema: type: object additionalProperties: false - required: [person_id, registration_status, eligible, guardian_id] + required: [person_id, registration_status, residency_confirmed, guardian_id] properties: person_id: { type: string, maxLength: 64 } registration_status: { type: [string, "null"], maxLength: 32 } - eligible: { type: [boolean, "null"] } + residency_confirmed: { type: [boolean, "null"] } guardian_id: { type: [string, "null"], maxLength: 64 } materialization: max_records: 1000000 diff --git a/crates/registryctl/tests/fixtures/project-authoring/snapshot-with-records/environments/local.yaml b/crates/registryctl/tests/fixtures/project-authoring/snapshot-with-records/environments/local.yaml index 90885c70f..1fa460442 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/snapshot-with-records/environments/local.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/snapshot-with-records/environments/local.yaml @@ -8,7 +8,7 @@ entities: columns: person_id: subject_key registration_status: status_code - eligible: benefit_eligible + residency_confirmed: residency_flag guardian_id: guardian_key source_revision: population-export-v1 generation: 2026-07-13 diff --git a/crates/registryctl/tests/fixtures/project-authoring/snapshot-with-records/integrations/person-snapshot/fixtures/match.yaml b/crates/registryctl/tests/fixtures/project-authoring/snapshot-with-records/integrations/person-snapshot/fixtures/match.yaml index f3ae1d58c..bbe7cc7d4 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/snapshot-with-records/integrations/person-snapshot/fixtures/match.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/snapshot-with-records/integrations/person-snapshot/fixtures/match.yaml @@ -3,13 +3,13 @@ classification: synthetic input: { person_id: PER-00000001 } interactions: - expect: { method: GET, path: /snapshot } - respond: { status: 200, body: { registration_status: active, eligible: true } } + respond: { status: 200, body: { registration_status: active, residency_confirmed: true } } expect: outcome: match - outputs: { registration_status: active, eligible: true } + outputs: { registration_status: active, residency_confirmed: true } claims: population-record-exists: true - benefits-status: active - benefits-eligible: true + population-registration-status: active + residency-confirmed: true emergency-record-exists: true emergency-status: redacted diff --git a/crates/registryctl/tests/fixtures/project-authoring/snapshot-with-records/integrations/person-snapshot/fixtures/no-match.yaml b/crates/registryctl/tests/fixtures/project-authoring/snapshot-with-records/integrations/person-snapshot/fixtures/no-match.yaml index 972dad2b4..55a1e18a6 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/snapshot-with-records/integrations/person-snapshot/fixtures/no-match.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/snapshot-with-records/integrations/person-snapshot/fixtures/no-match.yaml @@ -9,7 +9,7 @@ expect: outputs: {} claims: population-record-exists: false - benefits-status: null - benefits-eligible: null + population-registration-status: null + residency-confirmed: null emergency-record-exists: false emergency-status: redacted diff --git a/crates/registryctl/tests/fixtures/project-authoring/snapshot-with-records/integrations/person-snapshot/integration.yaml b/crates/registryctl/tests/fixtures/project-authoring/snapshot-with-records/integrations/person-snapshot/integration.yaml index 4baa2fd06..5dbae4f8e 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/snapshot-with-records/integrations/person-snapshot/integration.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/snapshot-with-records/integrations/person-snapshot/integration.yaml @@ -13,7 +13,7 @@ capability: exact: person_id: { input: person_id } freshness: 24h -outputs: [registration_status, eligible] +outputs: [registration_status, residency_confirmed] not_applicable: ambiguity: rationale: The exact snapshot selector is the entity primary key, whose materialized unique-key constraint permits at most one record. diff --git a/crates/registryctl/tests/fixtures/project-authoring/snapshot-with-records/registry-stack.yaml b/crates/registryctl/tests/fixtures/project-authoring/snapshot-with-records/registry-stack.yaml index 490a48ade..444941552 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/snapshot-with-records/registry-stack.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/snapshot-with-records/registry-stack.yaml @@ -16,7 +16,7 @@ services: aggregate: people:aggregate evidence_verification: people:evidence_verification purposes: [case-management] - projection: [person_id, registration_status, eligible] + projection: [person_id, registration_status, residency_confirmed] pagination: { default_limit: 50, max_limit: 100 } filters: { person_id: [eq] } required_principal_filters: [person_id] @@ -34,14 +34,14 @@ services: input: { person_id: request.target.identifiers.population_person_id } claims: population-record-exists: { cel: person.matched, disclosure: predicate } - benefits-status: { output: person.registration_status, disclosure: value } - benefits-eligible: { output: person.eligible, disclosure: predicate } + population-registration-status: { output: person.registration_status, disclosure: value } + residency-confirmed: { output: person.residency_confirmed, disclosure: predicate } credential_profiles: - benefits-status: + population-evidence: format: dc+sd-jwt - type: https://credentials.invalid/benefits-status/v1 + type: https://credentials.invalid/population-evidence/v1 validity: 10m - claims: [population-record-exists, benefits-status, benefits-eligible] + claims: [population-record-exists, population-registration-status, residency-confirmed] emergency-assistance: kind: evidence version: 1 diff --git a/crates/registryctl/tests/init_output.rs b/crates/registryctl/tests/init_output.rs index 242d0c568..8701b40a2 100644 --- a/crates/registryctl/tests/init_output.rs +++ b/crates/registryctl/tests/init_output.rs @@ -284,12 +284,6 @@ fn json_init_rejects_non_utf8_destinations_before_all_dispatches() { &["--format", "json"][..], true, ), - ( - "spreadsheet-api", - &["init", "spreadsheet-api"][..], - &["--format", "json"][..], - true, - ), ] { let mut leaf = format!("{name}-").into_bytes(); leaf.push(0xff); diff --git a/crates/registryctl/tests/project_authoring.rs b/crates/registryctl/tests/project_authoring.rs index abdda82d4..13562a0b4 100644 --- a/crates/registryctl/tests/project_authoring.rs +++ b/crates/registryctl/tests/project_authoring.rs @@ -481,7 +481,7 @@ fn project_check_collects_separate_integration_and_fixture_yaml_errors() { "version: [\n", ) .expect("invalid integration writes"); - let fixture_path = project.join("integrations/eligibility/fixtures/eligible.yaml"); + let fixture_path = project.join("integrations/eligibility/fixtures/source-approved.yaml"); let mut fixture = std::fs::read_to_string(&fixture_path).expect("fixture reads"); fixture.push_str("unknown_authoring_field: true\n"); std::fs::write(&fixture_path, fixture).expect("unknown fixture field writes"); @@ -506,7 +506,7 @@ fn project_check_collects_separate_integration_and_fixture_yaml_errors() { let fixture = report .diagnostics .iter() - .find(|diagnostic| diagnostic.file.ends_with("fixtures/eligible.yaml")) + .find(|diagnostic| diagnostic.file.ends_with("fixtures/source-approved.yaml")) .expect("fixture unknown-field diagnostic"); assert_eq!(fixture.code, "registryctl.authoring.yaml.unknown_field"); assert!(fixture.line.is_some()); @@ -521,7 +521,7 @@ fn project_check_collects_separate_integration_and_fixture_yaml_errors() { fn project_check_single_error_report_is_concise_and_typed() { let temporary = tempfile::tempdir().expect("temporary directory"); let project = copy_project("custom-system", temporary.path()); - let fixture = project.join("integrations/eligibility/fixtures/eligible.yaml"); + let fixture = project.join("integrations/eligibility/fixtures/source-approved.yaml"); std::fs::write(&fixture, "name: [\n").expect("invalid fixture writes"); let report = authoring_diagnostics(&project); assert_eq!(report.diagnostics.len(), 1, "{report:#?}"); @@ -540,7 +540,7 @@ fn project_check_cli_renders_the_same_typed_diagnostic_in_human_and_json() { let temporary = tempfile::tempdir().expect("temporary directory"); let project = copy_project("custom-system", temporary.path()); std::fs::write( - project.join("integrations/eligibility/fixtures/eligible.yaml"), + project.join("integrations/eligibility/fixtures/source-approved.yaml"), "name: [\n", ) .expect("invalid fixture writes"); @@ -605,7 +605,7 @@ fn project_check_cli_rejects_an_unselected_environment_symlink_with_typed_output symlink(&target, project.join("environments/zzz.yaml")) .expect("unselected environment symlink creates"); - let fixture_path = project.join("integrations/eligibility/fixtures/eligible.yaml"); + let fixture_path = project.join("integrations/eligibility/fixtures/source-approved.yaml"); let mut fixture = read_yaml(&fixture_path); fixture["expect"]["outputs"]["approved"] = serde_norway::Value::Bool(false); write_yaml(&fixture_path, &fixture); @@ -682,7 +682,7 @@ fn project_check_cli_reports_malformed_root_before_unselected_environment_bounda ) .expect("malformed referenced integration writes"); std::fs::write( - project.join("integrations/eligibility/fixtures/eligible.yaml"), + project.join("integrations/eligibility/fixtures/source-approved.yaml"), format!("{FIXTURE_MARKER}: [\n"), ) .expect("malformed fixture writes"); @@ -1328,6 +1328,148 @@ fn approved_opencrvs_and_dhis2_claim_sets_execute_offline() { } } +#[test] +fn dhis2_health_evidence_journey_preserves_distinct_results() { + let project = golden("dhis2-tracker"); + let report = test_registry_project(&ProjectTestOptions { + project_directory: project.clone(), + environment: None, + live: false, + }) + .expect("DHIS2 health evidence journey passes offline"); + assert_eq!(report.status, "passed"); + + let expected_outputs = [ + "bcg_birth_dose_recorded", + "child_health_visit_recorded", + "child_program_active", + "date_of_birth", + "first_name", + "last_name", + "maternal_postnatal_active", + "measles_dose_recorded", + "opv_birth_dose_recorded", + "programme_code", + "reconciliation_reference", + "tb_program_active", + ] + .map(String::from); + let expected_claims = [ + "bcg-birth-dose-recorded", + "child-age-band", + "child-health-visit-recorded", + "child-program-active", + "maternal-postnatal-care-active", + "measles-dose-recorded", + "opv-birth-dose-recorded", + "programme-code", + "reconciliation-reference", + "tb-program-active", + "tracked-entity-first-name", + "tracked-entity-last-name", + ] + .map(String::from); + + for fixture_name in [ + "complete-child-health-evidence", + "partial-child-health-evidence", + "no-child-program-enrollment", + ] { + let fixture = report + .fixtures + .iter() + .find(|fixture| fixture.fixture == fixture_name) + .unwrap_or_else(|| panic!("missing {fixture_name}")); + assert_eq!(fixture.outcome.as_deref(), Some("match")); + assert_eq!(fixture.outputs, expected_outputs); + assert_eq!(fixture.claims, expected_claims); + assert!(fixture.passed, "{fixture:#?}"); + } + + let no_match = report + .fixtures + .iter() + .find(|fixture| fixture.fixture == "health-no-match") + .expect("no-match fixture report"); + assert_eq!(no_match.outcome.as_deref(), Some("no_match")); + assert!(no_match.outputs.is_empty()); + assert_eq!(no_match.claims, expected_claims); + assert!(no_match.passed, "{no_match:#?}"); + + for (fixture_name, expected_error) in [ + ("health-source-rejected", "source.status_rejected"), + ("health-subject-mismatch", "failure.subject_mismatch"), + ] { + let fixture = report + .fixtures + .iter() + .find(|fixture| fixture.fixture == fixture_name) + .unwrap_or_else(|| panic!("missing {fixture_name}")); + assert_eq!(fixture.expected_error.as_deref(), Some(expected_error)); + assert_eq!(fixture.source_access, Some(true)); + assert!(fixture.outputs.is_empty()); + assert!(fixture.claims.is_empty()); + assert!(fixture.passed, "{fixture:#?}"); + } + + let malformed = report + .fixtures + .iter() + .find(|fixture| fixture.fixture.ends_with("::derived/malformed_decode")) + .expect("derived malformed-source fixture report"); + assert_eq!( + malformed.expected_error.as_deref(), + Some("source.response_malformed") + ); + assert_eq!(malformed.source_access, Some(true)); + assert!(malformed.passed, "{malformed:#?}"); + + let fixtures = project.join("integrations/health-record/fixtures"); + let complete = read_yaml(&fixtures.join("match.yaml")); + for claim in [ + "child-program-active", + "bcg-birth-dose-recorded", + "opv-birth-dose-recorded", + "measles-dose-recorded", + ] { + assert_eq!(complete["expect"]["claims"][claim].as_bool(), Some(true)); + } + + let partial = read_yaml(&fixtures.join("partial.yaml")); + assert_eq!( + partial["expect"]["claims"]["child-program-active"].as_bool(), + Some(false) + ); + assert_eq!( + partial["expect"]["claims"]["bcg-birth-dose-recorded"].as_bool(), + Some(false) + ); + assert!(partial["expect"]["claims"]["opv-birth-dose-recorded"].is_null()); + assert_eq!( + partial["expect"]["claims"]["measles-dose-recorded"].as_bool(), + Some(true) + ); + + for fixture_name in ["no-enrollment.yaml", "no-match.yaml"] { + let fixture = read_yaml(&fixtures.join(fixture_name)); + for claim in [ + "child-program-active", + "bcg-birth-dose-recorded", + "opv-birth-dose-recorded", + "measles-dose-recorded", + ] { + assert!( + fixture["expect"]["claims"][claim].is_null(), + "{fixture_name} must keep {claim} unknown" + ); + } + } + + let authored = read_yaml(&project.join("registry-stack.yaml")); + assert!(!yaml_contains_string(&authored, "eligible")); + assert!(!yaml_contains_string(&authored, "outreach")); +} + #[test] fn successful_negative_fixtures_report_the_closed_denial_assertion() { let report = test_registry_project(&ProjectTestOptions { @@ -1371,8 +1513,8 @@ fn successful_negative_fixtures_report_the_closed_denial_assertion() { let successful = report .fixtures .iter() - .find(|fixture| fixture.fixture == "eligible-household") - .expect("eligible fixture report"); + .find(|fixture| fixture.fixture == "source-approved-household") + .expect("source-approved fixture report"); assert_eq!(successful.expected_error, None); assert_eq!(successful.source_access, None); } @@ -1380,7 +1522,11 @@ fn successful_negative_fixtures_report_the_closed_denial_assertion() { #[test] fn exact_sources_report_reviewable_ambiguity_not_applicable_evidence() { for (project, integration, fixture) in [ - ("dhis2-tracker", "health-record", "complete-health-match"), + ( + "dhis2-tracker", + "health-record", + "complete-child-health-evidence", + ), ("openspp-exact", "individual", "social-registry-match"), ("snapshot-exact", "person-snapshot", "snapshot-match"), ] { @@ -1419,7 +1565,7 @@ fn exact_sources_report_reviewable_ambiguity_not_applicable_evidence() { #[test] fn response_contracts_without_comparable_identifiers_report_subject_mismatch_evidence() { for (project, integration, fixture) in [ - ("custom-system", "eligibility", "eligible-household"), + ("custom-system", "eligibility", "source-approved-household"), ("openspp-exact", "individual", "social-registry-match"), ("snapshot-exact", "person-snapshot", "snapshot-match"), ] { @@ -1571,8 +1717,8 @@ fn subject_mismatch_not_applicable_rejects_comparable_output_contract() { let project = copy_project("snapshot-exact", temporary.path()); replace_in_file( &project.join("integrations/person-snapshot/integration.yaml"), - "outputs: [registration_status, eligible]", - "outputs: [person_id, registration_status, eligible]", + "outputs: [registration_status, residency_confirmed]", + "outputs: [person_id, registration_status, residency_confirmed]", ); let error = test_registry_project(&ProjectTestOptions { project_directory: project, @@ -2905,7 +3051,7 @@ fn pre_freeze_fact_authoring_keys_are_rejected_without_aliases() { let fixture_root = tempfile::tempdir().expect("fixture-key temporary directory"); let fixture = copy_project("custom-system", fixture_root.path()); - let fixture_path = fixture.join("integrations/eligibility/fixtures/eligible.yaml"); + let fixture_path = fixture.join("integrations/eligibility/fixtures/source-approved.yaml"); replace_in_file(&fixture_path, " outputs:", " facts:"); let error = test_registry_project(&ProjectTestOptions { project_directory: fixture, @@ -3007,7 +3153,7 @@ fn authored_unknown_fields_and_traversal_fail_closed() { fn fixture_failure_reports_safe_validation_error_without_input_value() { let temporary = tempfile::tempdir().expect("temporary directory"); let project = copy_project("custom-system", temporary.path()); - let fixture_path = project.join("integrations/eligibility/fixtures/eligible.yaml"); + let fixture_path = project.join("integrations/eligibility/fixtures/source-approved.yaml"); replace_in_file(&fixture_path, "HH-AB12CD34", "invalid-reference"); let error = test_registry_project(&ProjectTestOptions { @@ -3022,7 +3168,7 @@ fn fixture_failure_reports_safe_validation_error_without_input_value() { "{diagnostic}" ); assert!( - diagnostic.contains("integrations/eligibility/fixtures/eligible.yaml"), + diagnostic.contains("integrations/eligibility/fixtures/source-approved.yaml"), "{diagnostic}" ); assert!( @@ -4106,7 +4252,7 @@ fn dci_exact_and_and_full_date_inputs_fail_closed_before_source_access() { let temporary = tempfile::tempdir().expect("temporary directory"); let project = copy_project("custom-system", temporary.path()); extend_exact_selector(&project, "custom-system", 4); - let fixture = project.join("integrations/eligibility/fixtures/eligible.yaml"); + let fixture = project.join("integrations/eligibility/fixtures/source-approved.yaml"); replace_in_file(&fixture, "2017-06-15", "2017-02-31"); let error = test_registry_project(&ProjectTestOptions { project_directory: project, @@ -4119,7 +4265,7 @@ fn dci_exact_and_and_full_date_inputs_fail_closed_before_source_access() { let temporary = tempfile::tempdir().expect("temporary directory"); let project = copy_project("custom-system", temporary.path()); extend_exact_selector(&project, "custom-system", 3); - let fixture = project.join("integrations/eligibility/fixtures/eligible.yaml"); + let fixture = project.join("integrations/eligibility/fixtures/source-approved.yaml"); let mut document = read_yaml(&fixture); document["input"] .as_mapping_mut() @@ -4256,7 +4402,7 @@ fn check_and_build_produce_deterministic_product_inputs() { .pointer("/services/household-eligibility/consultations") .is_some()); assert!(explanation - .pointer("/services/household-eligibility/claims/household-eligible/cel") + .pointer("/services/household-eligibility/claims/source-household-approval-decision/cel",) .and_then(serde_json::Value::as_str) .is_some()); assert!(explanation @@ -4308,7 +4454,7 @@ fn check_and_build_produce_deterministic_product_inputs() { assert_eq!(first_closure, directory_closure(&output)); assert_eq!( closure_digest(&first_closure), - "bb52a3962eeed19d05577e23b6f092c02cb3a0fc18481da469a98517a57df9d5", + "f7202608870d8f7da613ce3b355f6c4c4d99fc7e863c135eb1d61e33f65806db", "project inputs must match the cross-machine golden digest" ); } @@ -4436,7 +4582,7 @@ fn generated_snapshot_contracts_activate_through_notary_at_the_authoring_bound() .evidence .claims .iter() - .find(|claim| claim.id == "benefits-status") + .find(|claim| claim.id == "population-registration-status") .expect("registry-backed snapshot claim"); let ClaimEvidenceMode::RegistryBacked { consultations } = &claim.evidence_mode else { panic!("snapshot claim remains registry-backed"); @@ -5549,7 +5695,7 @@ registry_type: civil-registry record_type: person identifiers: { person_id: person_id } expression_fields: { registration_status: registration_status } -response_fields: { eligible: eligible } +response_fields: { residency_confirmed: residency_confirmed } "#, ) .expect("SP DCI mapping"); @@ -6199,8 +6345,8 @@ fn verified_signed_baseline_classifies_semantic_review_dimensions_independently( let authored = std::fs::read_to_string(&project_file) .expect("project reads") .replace( - "household.approved != null ? household.matched && household.approved : false", - "household.approved != null ? household.matched && household.approved == true : false", + "household.matched && household.approved != null ? household.approved : null", + "household.matched && household.approved != null ? household.approved == true : null", ); std::fs::write(&project_file, authored).expect("claim-only edit writes"); let changed = check_registry_project(&ProjectCheckOptions { @@ -6798,12 +6944,12 @@ fn remove_custom_cel_claim(project: &Path) { .as_mapping_mut() .expect("custom claims") .remove(serde_norway::Value::String( - "household-eligible".to_string(), + "source-household-approval-decision".to_string(), )); service["credential_profiles"]["household-eligibility"]["claims"] .as_sequence_mut() .expect("custom credential claims") - .retain(|claim| claim.as_str() != Some("household-eligible")); + .retain(|claim| claim.as_str() != Some("source-household-approval-decision")); write_yaml(&project_path, &document); for fixture in std::fs::read_dir(project.join("integrations/eligibility/fixtures")) .expect("custom fixture directory") @@ -6817,7 +6963,7 @@ fn remove_custom_cel_claim(project: &Path) { .and_then(serde_norway::Value::as_mapping_mut); if let Some(claims) = claims { claims.remove(serde_norway::Value::String( - "household-eligible".to_string(), + "source-household-approval-decision".to_string(), )); } write_yaml(&path, &document); diff --git a/docs/site/astro.config.mjs b/docs/site/astro.config.mjs index a22b5831b..3df14bd1c 100644 --- a/docs/site/astro.config.mjs +++ b/docs/site/astro.config.mjs @@ -390,6 +390,7 @@ export default defineConfig({ { label: 'RS-TERMS · Terms', slug: 'spec/rs-terms' }, { label: 'RS-ARC-G · Architecture', slug: 'spec/rs-arc-g' }, { label: 'RS-PR-NOTARY · Notary protocol', slug: 'spec/rs-pr-notary' }, + { label: 'RS-PR-REGISTRYCTL · registryctl contract', slug: 'spec/rs-pr-registryctl' }, { label: 'RS-PR-RELAY · Relay protocol', slug: 'spec/rs-pr-relay' }, { label: 'RS-SEC-G · Security model', slug: 'spec/rs-sec-g' }, { label: 'RS-DM-CLAIM · Claim definition model', slug: 'spec/rs-dm-claim' }, diff --git a/docs/site/scripts/check-registryctl-tutorials.sh b/docs/site/scripts/check-registryctl-tutorials.sh index 77e703049..de8c556b2 100644 --- a/docs/site/scripts/check-registryctl-tutorials.sh +++ b/docs/site/scripts/check-registryctl-tutorials.sh @@ -15,7 +15,7 @@ REPO_ROOT="$(cd "$SITE_ROOT/../.." && pwd)" HELPER="$SITE_ROOT/scripts/registryctl-tutorial.mjs" RELAY_TUTORIAL="$SITE_ROOT/src/content/docs/tutorials/publish-spreadsheet-secured-registry-api.mdx" NOTARY_TUTORIAL="$SITE_ROOT/src/content/docs/tutorials/verify-claim-registry-api.mdx" -BUILDER_IMAGE="rust:1.95-bookworm@sha256:4c2fd73ef19c5ef9d54bee03b06b2839a392604fbfcd578ed948b71b37c1d7fb" +BUILDER_IMAGE="rust:1.95-trixie@sha256:f49565f188ee00bc2a18dd418183f2c5f23ef7d6e691890517ed341a598f67c3" LINUX_TARGET="$REPO_ROOT/target/registryctl-tutorial-linux-amd64" CARGO_HOME_DIR="$REPO_ROOT/target/registryctl-tutorial-cargo-home" NATIVE_TARGET="$REPO_ROOT/target/registryctl-tutorial-native" diff --git a/docs/site/scripts/generate-project-starters.test.mjs b/docs/site/scripts/generate-project-starters.test.mjs index 6289e310c..dd7c39554 100644 --- a/docs/site/scripts/generate-project-starters.test.mjs +++ b/docs/site/scripts/generate-project-starters.test.mjs @@ -65,7 +65,7 @@ test('derives all advertised starter selections from committed workspaces', asyn { starter: 'dhis2-tracker', integration: 'health-record', - fixture: 'complete-health-match', + fixture: 'complete-child-health-evidence', }, { starter: 'opencrvs-dci', diff --git a/docs/site/src/content/docs/changelog.mdx b/docs/site/src/content/docs/changelog.mdx index 6f1080e9f..f43fe4caf 100644 --- a/docs/site/src/content/docs/changelog.mdx +++ b/docs/site/src/content/docs/changelog.mdx @@ -16,6 +16,12 @@ documents. Per-product release notes live in each product repository; the entries below link to the relevant product pages on this site rather than duplicating release notes. +## 2026-07-23 + +- Made the Relay-to-Notary-to-evidence-consumer responsibility boundary normative for 1.0 project + authoring. Updated Notary guidance and registryctl examples to keep reusable evidence separate + from consumer-owned requirements, decisions, workflow, and action rules. + ## 2026-07-20 Documentation updates for the v0.12.2 beta-16 fix-forward release: diff --git a/docs/site/src/content/docs/explanation/architecture.mdx b/docs/site/src/content/docs/explanation/architecture.mdx index deef031db..f313c909a 100644 --- a/docs/site/src/content/docs/explanation/architecture.mdx +++ b/docs/site/src/content/docs/explanation/architecture.mdx @@ -64,6 +64,45 @@ updated policy documents without touching deployment config. topologies." /> +## Evidence and decision ownership + +The following responsibility flow is normative for 1.0 project authoring: + +1. A source system owns its operational data and any decisions made inside that source. +2. Registry Relay owns source-specific acquisition, source-access policy, and typed normalization. + Relay returns only the declared consultation outcome and typed outputs. It does not assign a + consumer consequence to those outputs. +3. Registry Notary owns atomic, precise, reusable evidence statements plus the authorization and + disclosure policy for evaluating and releasing them. Purpose-bound authorization controls who + can request evidence and what can be disclosed. It does not turn the evidence statement into a + consumer rule. +4. An evidence consumer determines how returned evidence is used. The accountable decision owner + retains responsibility for requirements, eligibility, qualification, prioritization, approval, + routing, payment, workflow, and action policy. + +These are three different policy categories: Relay source-access and adaptation policy, Notary +evidence authorization and disclosure policy, and consumer use, decision, and action policy. +Project authors must keep them separate even when one deployment operates every component. The +caller, evidence consumer, and decision owner can be the same component or separate components. + +An evidence consumer can be a social-protection programme, admissions service, licensing +authority, healthcare workflow, lender, insurer, or credential verifier. The caller is the +technical client that invokes Notary, which may be a portal, intermediary, wallet backend, or +workflow connector acting for the consumer. + +| Context | Caller | Evidence consumer | Decision owner | +| --- | --- | --- | --- | +| Social protection | Case-management connector | Programme workflow | Administering authority | +| Public administration | Procedure portal or intermediary | Online procedure | Competent authority | +| Education | Admissions portal | Admissions workflow | Education institution | +| Healthcare | Clinical or case system | Care or public-health workflow | Provider or health authority | +| Regulated private service | Application platform | Lending or insurance workflow | Lender or insurer | + +Registry Notary can attest a decision already made by an authoritative source system. The claim +identifier and its review documentation must identify that fact as a source-owned decision. +Notary attests what the source decided; it does not recompute the decision or take ownership of +the source's policy. + ## Data and contract flow 1. Registry Platform provides reusable security and operational primitives consumed by runtime services. @@ -78,12 +117,15 @@ updated policy documents without touching deployment config. endpoints through configured auth. 5. Registry Relay executes compiler-pinned consultations. Its product-neutral `http`, `script`, and `snapshot` capabilities adapt reviewed source contracts and return only declared typed - outputs. Registry Notary evaluates registry-backed claims from those Relay outputs, or evaluates - source-free and self-attested claims without Relay. It renders evaluation results as + outputs. Registry Notary evaluates registry-backed evidence claims from those Relay outputs, or + evaluates source-free and self-attested evidence claims without Relay. It renders evaluation + results as `application/vnd.registry-notary.claim-result+json` - or CCCEV-shaped JSON-LD (`application/ld+json; profile="cccev"`), and it materializes eligible - stored evaluations into SD-JWT VC credentials (`application/dc+sd-jwt`). -6. A trusted Registry Notary can call another trusted Registry Notary through + or CCCEV-shaped JSON-LD (`application/ld+json; profile="cccev"`), and it materializes stored + evaluations that pass issuance checks into SD-JWT VC credentials (`application/dc+sd-jwt`). +6. An evidence consumer receives the returned evidence. The decision owner applies any + consumer-specific requirement, decision, workflow, or action rules outside Relay and Notary. +7. A trusted Registry Notary can call another trusted Registry Notary through `POST /federation/v1/evaluations` for signed delegated evaluation. Registry Manifest can publish discovery metadata for that relationship, but local Notary peer policy grants access. For what each project does and does not own, refer to the product overview pages linked in the capabilities table. diff --git a/docs/site/src/content/docs/explanation/disclosure-modes-and-computed-answers.mdx b/docs/site/src/content/docs/explanation/disclosure-modes-and-computed-answers.mdx index ebd40faa7..021d6085b 100644 --- a/docs/site/src/content/docs/explanation/disclosure-modes-and-computed-answers.mdx +++ b/docs/site/src/content/docs/explanation/disclosure-modes-and-computed-answers.mdx @@ -16,15 +16,17 @@ still answering questions about the subjects in it. The short answer is that a caller asks a question and receives a *computed answer*, not the record. This page explains how that works, what each kind of answer does and does not reveal, and where the privacy claim has edges. -You will see one product term used throughout: a **claim**, which is a single pre-modelled -question (one decision or one extracted value), such as "is this person registered?" or "what is -this person's registration date?". -A claim is deliberately narrow: a claim that tried to return a whole record would over-collect and -be hard to authorize, which is exactly the outcome this design avoids. +You will see one product term used throughout: an **evidence claim**, which is one atomic, +precisely defined evidence statement or extracted value, such as "is this person registered?" or +"what is this person's registration date?". +A claim is deliberately narrow and reusable. A claim that returned a whole record would +over-collect, and a claim that embedded one consumer's eligibility or action rules could not be +reused as neutral evidence. ## How Registry Notary controls what leaves the service -Registry Notary is the component that evaluates a claim and decides what the caller receives back. +Registry Notary is the component that evaluates an evidence claim and enforces what the caller +receives back. It controls the answer through three **disclosure modes**: `value`, `predicate`, and `redacted`. There are exactly three. `value` is not always all-or-nothing, though: when a claim returns an object, `value` mode can @@ -49,18 +51,36 @@ inputs. The caller does not supply the evaluated value, and cannot inject one. The evaluation runs as a pipeline: Relay returns a closed typed consultation result when the claim -is registry-backed, a rule computes the configured condition, a disclosure mode shapes what leaves -the service, and a response format carries the result. +is registry-backed, a rule evaluates the configured evidence statement, a disclosure mode shapes +what leaves the service, and a response format carries the result. Because Notary computes the answer from the closed Relay result or permitted self-attestation, it can return that computed answer rather than handing back a source record. The rule that does the computing is one of three kinds. An **exists** rule checks whether the pinned consultation produced one admissible match. An **extract** rule reads a declared typed output. -A **cel** rule derives a value from typed outputs, request variables, or earlier claim results -through a hardened expression. +A **`cel`** rule derives an evidence value from typed outputs, request variables, or earlier claim +results through a hardened expression. Each kind produces an answer about the subject without that answer being a copy of the source row. +## Evidence is not a consumer decision + +Registry Relay source-access and adaptation policy controls how an authoritative source is read +and normalized. Registry Notary evidence policy controls which evidence statement is evaluated, +who can request it, and how much can be disclosed. The evidence consumer determines how the +evidence is used, while the decision owner remains accountable for requirements, decisions, and +actions. + +A Common Expression Language (CEL) rule can derive an evidence predicate, such as whether the +source records an active programme enrollment or a measles dose. It is not a general-purpose +consumer eligibility or decision engine. A public-health programme, for example, can act as both +evidence consumer and decision owner when it combines those evidence claims with its own +thresholds, priorities, and case state to decide whether outreach or follow-up is required. + +Registry Notary can also attest a decision that an authoritative source already owns. The claim +identifier and its review documentation must say that it is a source-owned decision. Notary does +not recompute that decision or present the source's decision policy as Notary policy. + ## The three disclosure modes The disclosure mode fixes how much of the computed answer the caller receives: @@ -87,9 +107,8 @@ A question of whether someone has a registered record is modelled as an `exists` matching record returns `evidence.not_available` (collapsed to a single public reason by default) rather than `false`; absence is surfaced as no-evidence, not as a negative result. Either way the source row never leaves the service. -A question about an eligibility threshold without exposing the figure behind it is modelled as a -`cel` rule whose eligibility boolean is disclosed as `predicate`; the underlying value stays -inside the service. +A source fact such as whether a recorded vaccination dose is present can be modelled as a boolean +evidence predicate without returning the underlying health record. A `redacted` result goes further: it carries neither the source value nor the satisfaction outcome. @@ -99,6 +118,25 @@ The standard result body reports no `satisfied` value, and the CCCEV JSON-LD ren `predicate` still discloses a true-or-false fact; `redacted` does not. +## Preserve negative, unknown, and unavailable evidence + +Claim authors must keep these source meanings distinct: + +- `true` means the reviewed source contract positively establishes the evidence statement. +- `false` means the reviewed source contract positively establishes its negative. +- `null` means a subject matched but the declared source value is unknown, absent, or not recorded. +- `no_match` means the exact selector did not resolve a subject. +- `ambiguous` means the selector resolved more than one admissible subject. +- A source failure means the source could not provide a reliable result. + +A claim rule must not silently coerce `null`, `no_match`, `ambiguous`, or source failure to +`false`. Missing evidence is not a negative fact. An explicitly named existence predicate is the +narrow exception: a reviewed rule can map `matched == false` to `false` only when the claim means +that one admissible match exists. This exception does not make other claims negative, and ambiguity +or source failure never becomes `false`. A public disclosure policy can collapse matching failures +to `evidence.not_available`, but the reviewed source outcome and audit provenance remain distinct +from an explicit negative source value. + ## How disclosure policy is configured per claim The caller does not freely pick from all three modes. diff --git a/docs/site/src/content/docs/explanation/integration-patterns.mdx b/docs/site/src/content/docs/explanation/integration-patterns.mdx index 0a8d46d3b..3c2d10613 100644 --- a/docs/site/src/content/docs/explanation/integration-patterns.mdx +++ b/docs/site/src/content/docs/explanation/integration-patterns.mdx @@ -64,6 +64,33 @@ such as a social-protection management information system do not become raw-reco owns the eligibility decision and reads the registry it owns directly." /> +## Evidence-to-action boundary + +Every combined integration follows the same ownership boundary. Registry Relay owns +source-specific acquisition, source-access policy, and typed normalization. Registry Notary owns +atomic evidence claims plus evidence authorization and disclosure policy. The evidence consumer +determines how the evidence is used, and the decision owner remains accountable for requirements, +decisions, workflow, and action. + +The DHIS2 health-evidence starter makes that boundary visible. DHIS2 remains the system of record. +Relay uses its product-neutral script capability to perform bounded, same-origin acquisition and +normalize the existing health outputs plus `bcg_birth_dose_recorded`, +`opv_birth_dose_recorded`, and `measles_dose_recorded`. Notary exposes those facts as reusable +evidence claims. In this example, a public-health programme is both the evidence consumer and the +decision owner. It can compute `outreach-required` or `follow-up-priority` without moving either +decision into Relay or Notary. + +The reviewed contract keeps `true`, `false`, `null`, no-match, ambiguity, and source failure +distinct. Missing evidence is not a negative fact. Offline synthetic fixtures are the deterministic +acceptance path; a governed live check against a reachable DHIS2 instance is optional compatibility +evidence. + +Registry Notary may attest a decision that DHIS2 or another authoritative source already made only +when the claim identifier and its review documentation identify it as a source-owned decision. +This exception does not make Notary a general-purpose eligibility or workflow engine. + +{/* Evidence: crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/. */} + ## Domain registry platforms Examples: [OpenCRVS](https://www.opencrvs.org/) (civil registration), [OpenSPP](https://openspp.org/) @@ -176,7 +203,8 @@ source tables, raw SQL, or full records. Wire Registry Stack in alongside a workflow engine when: -- A workflow step needs an authoritative registry output (status, predicate, selected value). +- A workflow step needs an authoritative evidence fact (status, predicate, selected value) as an + input to the workflow engine's own decision. - A workflow step needs an evidence artifact or credential rather than an internal service result. - Generated workflow connectors must not depend on source schema details. diff --git a/docs/site/src/content/docs/explanation/records-stay-home.mdx b/docs/site/src/content/docs/explanation/records-stay-home.mdx index af056a838..9d2b7ddf7 100644 --- a/docs/site/src/content/docs/explanation/records-stay-home.mdx +++ b/docs/site/src/content/docs/explanation/records-stay-home.mdx @@ -14,9 +14,9 @@ standards_referenced: An institution that runs a civil registry, a social-protection database, or a health registry already holds the records it needs. -Registry Stack lets it **answer questions about those records** (*is this person alive? is -this household eligible?*) and return a result another system can trust, while the records -themselves are **read where they already live, never written back, and not copied into a +Registry Stack lets it **answer evidence questions about those records** (*is this person alive? +is this programme enrollment active?*) and return a result another system can trust, while the +records themselves are **read where they already live, never written back, and not copied into a central exchange**. This page explains what that means in practice: what stays inside the institution's boundary, what crosses it, and (equally important) what the design does and does not @@ -24,13 +24,15 @@ guarantee. ## A question goes in, an answer comes out -The combined Relay and Notary mental model is one sentence: **a scoped question crosses into the -institution, Relay reads the record in place, and Notary returns a computed answer.** +The combined Relay and Notary mental model is one sentence: **a scoped evidence question crosses +into the institution, Relay reads and normalizes the source, Notary returns a governed evidence +answer, an evidence consumer uses it, and the decision owner remains accountable for what happens +next.** A Notary caller never sends the value it is asking about and never receives the underlying record as the answer. -It sends the id of a *claim* (a single, pre-modelled question) and only the typed target, -requester, or variable inputs that the claim admits. +It sends the id of an *evidence claim* (an atomic, precisely defined evidence statement) and only +the typed target, requester, or variable inputs that the claim admits. It receives one of a few narrow shapes of answer: a yes/no, a single value, a machine-readable evaluation result, or a credential the subject can carry in a wallet. The source row that the answer was computed from stays behind. @@ -50,10 +52,10 @@ flowchart LR notary -. records .-> audit key -. signs .-> notary end - caller["Caller / verifier"] + caller["Caller / evidence consumer"] holder(["Subject / wallet"]) caller == "request: claim id + subject inputs + scope" ==> notary - notary == "answer: yes/no · value · evaluation result" ==> caller + notary == "evidence: yes/no · value · evaluation result" ==> caller notary == "issued credential" ==> holder caller == "scoped record read" ==> relay relay == "authorized source records" ==> caller @@ -74,6 +76,15 @@ applies disclosure policy, and issues credentials. Notary is the strongest minimization surface. Relay record reads are scoped and audited, not open data. +The policy boundary remains separate across the two products and their consumers. Relay owns +source access and adaptation policy. Notary owns evidence authorization and disclosure policy. +The evidence consumer determines how evidence is used, and the decision owner remains accountable +for requirements, eligibility, qualification, prioritization, approval, routing, payment, +workflow, and action policy. +Purpose-bound authorization can restrict an evidence request without changing which component owns +the consumer decision. The caller that invokes Notary can be the evidence consumer or a technical +intermediary acting for it. + ## What stays home - Source data is read in place: Relay reads sources as batch snapshots or table scans; @@ -101,7 +112,7 @@ governed, audited read bounded by the caller's per-dataset row scope and the dat configured filters and limits. Registry Notary returns the answer a rule computes from typed consultation outputs rather than the source row; keeping that answer narrow is a modelling discipline, because a well-modelled claim -returns one decision or one extracted value. +returns one reusable evidence statement or one extracted value. A Notary answer takes one of a few shapes: - A yes/no: only the true/false satisfaction of the modelled rule. @@ -148,9 +159,13 @@ By default, a record that does not resolve collapses to a single not-available r rather than a `false`, which hides the reason matching failed. An authorized caller can still distinguish a resolved record from a not-available response, so use this pattern for minimization, not for hiding whether a requested subject matched. -To check eligibility without exposing an income figure, derive the decision with an -expression rule and disclose the eligibility boolean as a `predicate`; the income value -stays home. +To report whether a source records a vaccination dose, derive that evidence predicate from the +declared typed source output. A public-health programme, acting as evidence consumer and decision +owner, can combine the result with other evidence and its own case state to decide whether outreach +or follow-up is required. +Registry Notary can attest an eligibility or other decision already made by an authoritative +source only when the claim identifier and its review documentation identify the fact as a +source-owned decision. Notary does not recompute that decision. ## Why the answer is not the record @@ -211,6 +226,15 @@ Security material: - Matching is only as strict as the Relay integration contract: Relay returns `match`, `no_match`, or `ambiguous` under the compiled selector and output rules. Notary does not replace that result with direct source matching or choose between ambiguous records. +- Missing evidence is not a negative fact: A matched source value of `false`, a matched `null` + value, `no_match`, `ambiguous`, and source failure have different reviewed meanings. Claim rules + must not collapse `null`, unavailable evidence, or failures to `false`. An explicitly named + existence predicate can map `matched == false` to `false` when its reviewed meaning is exactly + whether one admissible match exists; ambiguity and failure remain unavailable. +- Notary is not a consumer decision engine: Notary returns governed evidence. The evidence consumer + determines how that evidence is used, and the decision owner remains accountable for requirements, + decisions, workflow, and actions unless Notary is attesting a decision explicitly owned by the + authoritative source. - This is not zero-knowledge: A `predicate` answer is a policy-enforced boolean computed inside the service; SD-JWT selective disclosure is digest omission. Neither is a zero-knowledge proof. diff --git a/docs/site/src/content/docs/reference/glossary.mdx b/docs/site/src/content/docs/reference/glossary.mdx index 9f7b3a6e3..7a7066c8e 100644 --- a/docs/site/src/content/docs/reference/glossary.mdx +++ b/docs/site/src/content/docs/reference/glossary.mdx @@ -45,6 +45,9 @@ Product names are always in English, including on future translated pages.
CCCEV
Core Criterion and Core Evidence Vocabulary. Registry Manifest emits CCCEV-shaped requirement, evidence type, and evidence type list metadata. Registry Notary renders CCCEV-shaped JSON-LD for claim evaluation results. Media type: `application/ld+json; profile="cccev"`.
+
caller
+
The technical client that invokes Registry Relay or Registry Notary. A caller can be the evidence consumer or an intermediary, portal, wallet backend, or workflow connector acting for it.
+
claim evaluation
Registry Notary process that evaluates configured evidence rules over compiler-pinned Registry Relay outputs or permitted self-attestation and returns a structured claim result.
@@ -54,6 +57,9 @@ Product names are always in English, including on future translated pages.
consultation
A named use of one Registry Relay integration by a Registry Notary evidence service. One consultation can supply several Notary claims.
+
consumer decision
+
An eligibility, qualification, prioritization, approval, routing, payment, workflow, or other outcome determined outside Registry Relay and Registry Notary by or for an evidence consumer. The decision owner remains accountable for its rules and consequences.
+
DCAT
Data Catalog Vocabulary. W3C recommendation. Registry Relay and Registry Manifest emit DCAT-shaped JSON-LD catalogs. Spell out on first use per page: "Data Catalog Vocabulary (DCAT)".
@@ -72,6 +78,9 @@ Product names are always in English, including on future translated pages.
deployment
One activated deployment-bundle generation. Replicas of that generation are one logical deployment.
+
decision owner
+
The institution accountable for the requirements, rules, decisions, and actions that use evidence. The decision owner can operate the evidence consumer directly or rely on a separate caller or intermediary.
+
DPI
Digital Public Infrastructure. Shared, interoperable digital systems (identity, payments, data exchange) deployed at population scale to support public service delivery. These projects cover the registry-consultation slice of a DPI deployment.
@@ -99,6 +108,9 @@ Product names are always in English, including on future translated pages.
Evidence Gateway
Governed runtime path for evidence responses. A Relay read or consultation and a Notary claim evaluation pass trusted request and evidence context through configured authorization and disclosure policy before returning or denying a response. Registry-backed Notary claims consume compiler-pinned Relay results.
+
evidence consumer
+
A service or process that uses returned evidence. Examples include a social-protection programme, admissions service, licensing authority, healthcare workflow, lender, insurer, or credential verifier. The evidence consumer, caller, and decision owner can be the same component or separate components.
+
evidence service
A Registry Notary-owned service containing service policy, claims, disclosure, optional Registry Relay consultations, and optional credential profiles.
@@ -111,8 +123,8 @@ Product names are always in English, including on future translated pages.
evidence offering
Metadata entry that describes a verification capability and the access path a client uses to reach it. In the current stack it points a client from Registry Relay metadata to Registry Notary or another evidence service; Relay describes the offering but does not evaluate the claim.
-
evidence claim
-
Registry Stack product term for a verifiable claim, attestation, credential claim, or status assertion that Registry Notary evaluates from Relay outputs or permitted self-attestation.
+
Notary evidence claim
+
An atomic, precise, reusable evidence statement that Registry Notary evaluates from Relay outputs or permitted self-attestation. Notary owns the claim's evidence semantics, authorization, and disclosure policy. A claim does not import an evidence consumer's decision or action rules.
evidence type list
CCCEV list of evidence types that satisfy a requirement. In Registry Manifest, one list is one grouped option; multiple lists on the same requirement are alternatives.
@@ -135,11 +147,11 @@ Product names are always in English, including on future translated pages.
integration
A product-neutral source adaptation authored in a Registry Stack project. An integration declares reviewed metadata and one source capability such as `http`, `script`, or `snapshot`. Product and version labels do not select compiler or runtime behavior.
-
output
-
A typed, minimized scalar returned by Registry Relay on a matching consultation.
+
Relay output
+
A declared typed value returned by Registry Relay after source-specific acquisition and normalization on a matching consultation. A Relay output carries source evidence, not a consumer consequence. `true`, `false`, `null`, `no_match`, `ambiguous`, and source failure retain distinct meanings under the reviewed integration contract.
claim
-
A Registry Notary-owned, purpose-specific value or predicate derived from consultation outcome and outputs.
+
Short form for a Notary evidence claim: a Registry Notary-owned evidence value or predicate derived from a consultation outcome and typed Relay outputs, or from permitted self-attestation. Purpose-bound authorization limits use of the claim without turning the claim into consumer policy.
JSON-LD
JSON-based linked data format. W3C recommendation (JSON-LD 1.1). Used for catalog, policy, and CCCEV render output.
@@ -240,6 +252,9 @@ Product names are always in English, including on future translated pages.
source
The one logical registry data interface available to Registry Relay, when present, inside a project trust domain. An OAuth or JSON Web Key Set endpoint used by a protocol is not another data source.
+
source-owned decision
+
A decision already made and owned by an authoritative source system. Registry Notary may attest this fact only when the claim identifier and its review documentation identify it as source-owned; Notary does not recompute the decision or claim ownership of its policy.
+
`registry-manifest/v1`
Schema version string for portable metadata manifests.
diff --git a/docs/site/src/content/docs/reference/registryctl.mdx b/docs/site/src/content/docs/reference/registryctl.mdx index 649155827..c8eab547c 100644 --- a/docs/site/src/content/docs/reference/registryctl.mdx +++ b/docs/site/src/content/docs/reference/registryctl.mdx @@ -73,6 +73,7 @@ formatting. Artifact and protocol streams retain their native formats, including | Invalid-project `check` | `registryctl.project_diagnostics.v1` | | `authoring editor` | `registryctl.project_editor.v1` | | `doctor` | `registryctl.validation.report.v1` | +| `smoke` | `registryctl.smoke.v1` | | `bundle` or `anchor` | The operation-specific `schema_version` in the report | An initialized starter records its starter ID, Registry Stack release, and @@ -86,7 +87,9 @@ Without `--against`, `check` and `build` label the baseline Their `semantic_changes` entries identify changed `claim`, `integration`, `service_policy`, `operator_security`, and `disclosure` dimensions. The authorized bundle signer reviews that report; Registry Stack does not create a separate reviewer workflow. -Generated files are written under `.registry-stack/build//` only by `build`. +The `registryctl.project_command.v1` build report identifies the complete generated build root in +its `output` field. Automation must use that field instead of constructing a path from registryctl's +private generated directory layout. For invalid authoring, `check` exits nonzero and reports a nonempty typed diagnostic list. The default human output and `--format json` use the same diagnostics, stable codes, normalized @@ -135,6 +138,9 @@ The command copies the five JSON Schema Draft 2020-12 documents embedded in the `registryctl`. It records the binary version and schema SHA-256 values in `.registry-stack-editor/manifest.json`, so the project does not depend on a source checkout, a mutable branch, or network schema retrieval. +For automation, `authoring editor --format json` returns the project root in `project_directory` +and every managed file in `files`. Use those `registryctl.project_editor.v1` fields instead of +inferring additional editor paths. | Authored file | Project-local schema | | --- | --- | @@ -280,7 +286,7 @@ default and accept `--format json` for automation. ## Project setup -The legacy `init relay` command creates a local spreadsheet project. +The canonical `init relay` command creates a local Relay-backed spreadsheet API tutorial project. In registryctl `v0.12.2`, this generation command requires the strict `registryctl-vX.Y.Z-image-lock.json` from the same release beside the running binary. Set `REGISTRYCTL_IMAGE_LOCK` only when an operator has verified the same @@ -297,7 +303,9 @@ not need the lock. Registryctl `v0.8.4` predates the image-lock contract. The human-readable result identifies the generated Bruno collection and gives the validation and start commands. The JSON result contains no progress text, so another program can parse standard -output directly. +output directly. Automation must read the project root from `output` and supported generated entry +points from `artifacts` in the `registryctl.init.v1` report. The remaining generated layout is a +private implementation detail. {/* Evidence: crates/registryctl/src/main.rs and crates/registryctl/tests/init_output.rs. */} @@ -350,7 +358,9 @@ Inspect commands report on a running or generated project. ## Testing -Smoke commands run built-in local checks and write a JSON result file. +Smoke commands run built-in local checks and write a versioned JSON result file. The +`registryctl.smoke.v1` report is saved as `smoke-results.json` under the authored +`local.output_dir`. Smoke-check ordering and human-readable names are not compatibility contracts. | Subcommand | Purpose | | --- | --- | diff --git a/docs/site/src/content/docs/security/hardening-checklist.mdx b/docs/site/src/content/docs/security/hardening-checklist.mdx index 3de1522f5..a05dc8a98 100644 --- a/docs/site/src/content/docs/security/hardening-checklist.mdx +++ b/docs/site/src/content/docs/security/hardening-checklist.mdx @@ -146,9 +146,10 @@ or the [Registry Notary operator configuration reference](../../products/registr active waivers, so the deployment's actual state is inspectable rather than asserted. Read [RS-OP-POSTURE](../../spec/rs-op-posture/) before building operational tooling around its default or restricted tier. -- Give every waiver a non-empty, non-secret reason and a mandatory expiry date. `startup_fail` - gates are never waivable; a waiver naming one is rejected. Waiver reasons appear only in the - restricted posture tier. +- Give every waiver a validated operator reference and a mandatory expiry date. Add a short + summary only when operators need more context, and keep credentials and private keys out of + both fields. `startup_fail` and `readiness_fail` gates are never waivable; a waiver naming one + is rejected. Waiver references and summaries appear only in the restricted posture tier. - Use `deployment.evidence.*` flags (for example `ingress_rate_limit`, `api_key_rotation`) only to assert controls that live outside the process and cannot be observed by it. Each flag defaults to `false`. diff --git a/docs/site/src/content/docs/security/index.mdx b/docs/site/src/content/docs/security/index.mdx index e2979a592..548ca0716 100644 --- a/docs/site/src/content/docs/security/index.mdx +++ b/docs/site/src/content/docs/security/index.mdx @@ -105,9 +105,10 @@ Both runtime services accept a `deployment` block with a profile of `local`, `ho Each profile binds a findings catalog at escalating severities: a `production` deployment with no durable audit sink refuses to start, and an `evidence_grade` deployment refuses to start from an unsigned local config file. -Findings, waivers (each with a mandatory reason and expiry), and the declared profile are reported -by the operations posture endpoint, so the deployment's actual state is inspectable rather than -asserted. +Findings, waivers (each with a required operator reference, optional summary, and mandatory +expiry), and the declared profile are reported by the restricted operations posture endpoint, so +the deployment's actual state is inspectable rather than asserted. The default tier omits waiver +metadata. The gate catalogs are documented in the [Registry Relay configuration reference](../products/registry-relay/configuration/) and the [Registry Notary operator configuration reference](../products/registry-notary/operator-config-reference/). diff --git a/docs/site/src/content/docs/spec/rs-op-posture.mdx b/docs/site/src/content/docs/spec/rs-op-posture.mdx index 14746515c..de87e539a 100644 --- a/docs/site/src/content/docs/spec/rs-op-posture.mdx +++ b/docs/site/src/content/docs/spec/rs-op-posture.mdx @@ -32,9 +32,12 @@ source of secrets. The key words in this document are interpreted per [RS-DOC](../rs-doc/) Section 2. Defined terms are used per [RS-TERMS](../rs-terms/). -The [Registry Ops Posture v1 JSON Schema](https://github.com/registrystack/registry-stack/blob/c84b1b9288b925e7c9cc89c47c33cc1f50753d8c/crates/registry-platform-ops/schemas/registry.ops.posture.v1.schema.json) -is the source of truth for validation; this specification defines the operational meaning and -evolution rules that JSON Schema alone does not express. +{/* TODO[evidence]: Add a release-tagged Registry Ops Posture v1 JSON Schema source link after + the waiver definitions and shared portable metadata constraints merge. The earlier pinned + schema predates those definitions and is not evidence for this draft revision. */} +Portable structural validation is defined by the in-tree draft schema; release-tagged source +evidence for this revision is pending. This specification defines the operational meaning, +semantic producer validation, and evolution rules that JSON Schema alone does not express. ## Version history @@ -42,6 +45,9 @@ evolution rules that JSON Schema alone does not express. | --- | --- | --- | --- | | 0.1.0 | 2026-07-19 | draft | Initial posture-document contract for `registry.ops.posture.v1`. | | 0.1.1 | 2026-07-19 | draft | Added restricted Relay per-resource refresh health before the 1.0 schema freeze. | +| 0.1.2 | 2026-07-20 | draft | Replaced waiver reason text with validated operator references and optional summaries before the 1.0 schema freeze. | +| 0.1.3 | 2026-07-23 | draft | Reserved credential-shaped waiver-reference prefixes before the 1.0 schema freeze. | +| 0.1.4 | 2026-07-24 | draft | Separated portable waiver-schema checks from contextual semantic validation before the 1.0 schema freeze. | ## 1. Scope and surface @@ -116,6 +122,36 @@ restricted per-resource refresh health. The `notary` section reports claim and credential-profile counts plus state, credential-status, federation, OID4VCI, and optional subject-access observations. +### Deployment waiver metadata + +A deployment waiver names one finding and expiry date. Its `reference` is a required opaque +operator-local approval, incident, or ticket identifier. The optional `summary` adds short +operational context. Neither value grants authority or changes which gate severities can be +waived. + +REQ-OP-POSTURE-011: A producer that emits a deployment waiver MUST include `reference` as an +already canonical 1 to 128 byte string using only ASCII letters, digits, `.`, `_`, `:`, and `-`. +The reference MUST NOT contain `..`. +It MUST NOT start, case-insensitively, with the credential-shaped prefix `Bearer:` or +`Basic:`, directly or after `Authorization:`. +`OPS-2026-0042` is an accepted ticket-style reference. +If the producer emits `summary`, the summary MUST be an already trimmed 1 to 256 +Unicode-character string without control characters, an authorization value, or a private-key +begin marker. +The producer MUST omit `summary` when no summary was configured and MUST NOT emit the retired +`reason` property. +The JSON Schema enforces the reference syntax and reserved prefixes. It also enforces the +summary length, C0 and C1 control-character exclusion, and leading or trailing whitespace +exclusion. +The schema does not encode context-sensitive authorization-value or private-key marker detection. +Before emitting either waiver shape, a producer MUST apply semantic validation equivalent to +`validate_deployment_waiver_metadata`. +Schema acceptance alone does not satisfy the contextual exclusions in REQ-OP-POSTURE-011. +These metadata rules do not alter expiry behavior: an expired waiver stops suppressing its +finding. +They also do not alter the hard-gate boundary: `startup_fail` and `readiness_fail` findings +remain non-waivable. + ## 3. Examples The schema-valid [Registry Relay default example](https://github.com/registrystack/registry-stack/blob/c84b1b9288b925e7c9cc89c47c33cc1f50753d8c/crates/registry-platform-ops/examples/registry-relay.posture.valid.json) @@ -147,8 +183,8 @@ it in an opaque nested value. The default projection includes operational status, counts, selected digests, finding identifiers and severities, and non-secret audit-shipping observations. It excludes values that can reveal topology or operational free text, including instance URLs, -build revisions and features, trusted roots, artifact URLs, waiver reasons, signing-key details, -and Notary federation identities or peers. +build revisions and features, trusted roots, artifact URLs, waiver references and summaries, +signing-key details, and Notary federation identities or peers. The checked-in sensitive fixture proves that the projection excludes credentials, private keys, source URLs, subject identifiers, raw rows, claim values, and the restricted topology fields. The default tier also excludes `relay.refresh_health`, including its dataset and resource @@ -229,8 +265,9 @@ freshness service-level objective requires it. A producer conforms to this specification when it emits a schema-valid, component-consistent, tier-labeled observation; uses the emit-only allowlist for default output; keeps restricted output within the same secret and source-data boundary; assigns a new schema identifier for every -post-1.0 shape change; and reports Relay refresh health when applicable (REQ-OP-POSTURE-001 through -REQ-OP-POSTURE-006, REQ-OP-POSTURE-008, and REQ-OP-POSTURE-010). +post-1.0 shape change; reports Relay refresh health when applicable; and emits only validated +deployment waiver metadata (REQ-OP-POSTURE-001 through REQ-OP-POSTURE-006, +REQ-OP-POSTURE-008, REQ-OP-POSTURE-010, and REQ-OP-POSTURE-011). A consumer conforms when it validates and dispatches by schema, enforces component pairing, handles omissions as unreported, and does not elevate posture observations into proof or authority @@ -241,9 +278,8 @@ handles omissions as unreported, and does not elevate posture observations into This specification is `verified`: its document shape, examples, component pairing, and tier filter are shipped in Registry Platform and exercised by contract tests. -- The [v1 JSON Schema](https://github.com/registrystack/registry-stack/blob/c84b1b9288b925e7c9cc89c47c33cc1f50753d8c/crates/registry-platform-ops/schemas/registry.ops.posture.v1.schema.json) - defines the closed envelope, schema constant, and Relay/Notary component pairing - (Sections 2 and 5). +- Release-tagged source evidence for the v1 JSON Schema's waiver definitions and shared portable + metadata constraints is pending. The source pack will be linked after those changes merge. - The [posture contract tests](https://github.com/registrystack/registry-stack/blob/c84b1b9288b925e7c9cc89c47c33cc1f50753d8c/crates/registry-platform-ops/tests/posture_contract.rs) validate both examples, the default allowlist projection, the sensitive fixture, and exclusion of restricted fields from default output (Sections 3, 4, and 6). diff --git a/docs/site/src/content/docs/spec/rs-pr-registryctl.mdx b/docs/site/src/content/docs/spec/rs-pr-registryctl.mdx new file mode 100644 index 000000000..ae0268e70 --- /dev/null +++ b/docs/site/src/content/docs/spec/rs-pr-registryctl.mdx @@ -0,0 +1,243 @@ +--- +title: "RS-PR-REGISTRYCTL: registryctl compatibility contract" +description: "The normative 1.0 contract for registryctl project authoring, machine-readable reports, generated-output discovery, and exit statuses." +status: draft +owner: registry-docs +source_repos: + - registry-stack +last_reviewed: "2026-07-20" +doc_type: specification +doc_id: RS-PR-REGISTRYCTL +category: normative +evidence: verified +locale: en +standards_referenced: [] +layer: + - operations +audience: + - integrator + - maintainer + - tooling +--- + +This document defines the Registry Stack 1.0 compatibility contract for `registryctl`: the +authored project files that adopters maintain, the machine-readable reports that automation +consumes, how automation discovers generated outputs, and the process exit statuses. The +implementation meets these requirements, but the document remains `draft`; the compatibility +promise takes effect with Registry Stack `v1.0.0`, not with a pre-1.0 release. + +## Version history + +| Version | Date | Status | Change | +| --- | --- | --- | --- | +| 0.1.0 | 2026-07-20 | draft | Initial registryctl compatibility contract. | + +## 1. Scope and conventions + +The key words in this document are interpreted per [RS-DOC](../rs-doc/) Section 2. +This specification refines the command-line compatibility promise in the +[API stability and versioning reference](../../reference/api-stability/). + +This specification covers: + +- Authored Registry Stack project files rooted at `registry-stack.yaml`. +- Versioned JSON reports intended for automation. +- Report fields that identify supported generated outputs. +- Success, command-failure, and usage-failure exit statuses. +- The credential-issuance boundary enforced by project compilation. + +This specification does not stabilize human-readable output, private generated directory +internals, hidden commands, or implementation-owned worker protocols. + +REQ-PR-REGISTRYCTL-001: A released `registryctl` MUST identify every machine-readable report with +a `schema_version`. Within a major release, a report schema MUST remain backward compatible. + +## 2. Authored project contract + +A Registry Stack project starts at `registry-stack.yaml`. The root document references authored +environment, integration, fixture, and entity files. The strict schemas embedded in `registryctl` +define these files, while project-local editor setup makes the same schemas available to common +editors. The implementation and its path checks are in the +[project loader](https://github.com/registrystack/registry-stack/blob/1a9c33d31f4d4db523da0d36011358200696e16f/crates/registryctl/src/project_authoring/project.rs) +and [project diagnostics](https://github.com/registrystack/registry-stack/blob/1a9c33d31f4d4db523da0d36011358200696e16f/crates/registryctl/src/project_authoring/diagnostics.rs). + +REQ-PR-REGISTRYCTL-002: `registry-stack.yaml` and every authored file it references MUST be treated +as adopter-owned input. `registryctl` MUST NOT overwrite an existing nonempty project destination +during initialization. + +REQ-PR-REGISTRYCTL-003: The project, environment, integration, fixture, and entity schemas MUST +reject unknown fields. A minor or patch release MUST NOT remove a field, change its meaning, or +narrow its accepted values after `v1.0.0`. + +REQ-PR-REGISTRYCTL-004: Authored paths MUST remain inside the project root. Project loading MUST +reject path traversal, symbolic links, and unsupported file types before compilation or source +access. + +REQ-PR-REGISTRYCTL-005: `registryctl init --from` MUST validate a starter and its editor setup in +private staging before publishing the complete project. If validation or publication fails, the +command MUST leave an absent destination absent and a pre-existing empty destination empty. +The [initialization implementation](https://github.com/registrystack/registry-stack/blob/1a9c33d31f4d4db523da0d36011358200696e16f/crates/registryctl/src/project_authoring/commands.rs) +and its staging tests verify this behavior. + +## 3. Generated-output discovery + +`registryctl` generates product inputs only after the authored project and its offline fixtures +pass validation. Automation discovers supported output entry points from versioned reports instead +of constructing paths from a private directory layout. The current compiler publication boundary +is implemented in +[`output.rs`](https://github.com/registrystack/registry-stack/blob/1a9c33d31f4d4db523da0d36011358200696e16f/crates/registryctl/src/project_authoring/output.rs). + +REQ-PR-REGISTRYCTL-006: The `registryctl.init.v1` report MUST identify the project root in `output` +and supported generated entry points in `artifacts`. A consumer MUST use these fields instead of +assuming a generated filename. The `registryctl.project_editor.v1` report MUST identify the project +root in `project_directory` and every managed editor file in `files`. + +REQ-PR-REGISTRYCTL-007: A successful `build --format json` MUST return a +`registryctl.project_command.v1` report whose `output` field identifies the complete build root. +The exact files and directories below that root are implementation details unless another public +contract names them. + +REQ-PR-REGISTRYCTL-008: For identical authored inputs, environment, compiler version, and verified +baseline, `build` MUST produce the same file closure. Publication MUST replace the selected +environment output as one complete unit so product processes do not observe a partial closure. + +REQ-PR-REGISTRYCTL-009: Generated review material and private product inputs MUST remain separate. +The implementation MAY change their directory names, but MUST NOT place secrets or private source +bindings in the reviewable output. + +## 4. Machine-readable reports + +Commands that accept `--format json` emit one JSON document on standard output. Human progress +text does not share that stream. The command dispatch and JSON rendering are defined in the +[registryctl entry point](https://github.com/registrystack/registry-stack/blob/1a9c33d31f4d4db523da0d36011358200696e16f/crates/registryctl/src/main.rs). + +REQ-PR-REGISTRYCTL-010: A command in JSON format MUST write only its report to standard output. +Warnings that are not part of the report MAY use standard error, but MUST NOT change the JSON +document or a successful command's exit status. + +### 4.1 Doctor report + +`doctor --format json` emits `registryctl.validation.report.v1`. Its committed +[JSON Schema](https://github.com/registrystack/registry-stack/blob/1a9c33d31f4d4db523da0d36011358200696e16f/crates/registry-config-report/schemas/registryctl.validation.report.v1.schema.json) +and [canonical fixture](https://github.com/registrystack/registry-stack/blob/1a9c33d31f4d4db523da0d36011358200696e16f/crates/registry-config-report/fixtures/registryctl/registryctl.validation.error.json) +are the field-level contract. + +REQ-PR-REGISTRYCTL-011: A doctor report MUST contain `schema_version`, `project`, `status`, +`products`, and `generated_at`. It MAY contain `cross_product_diagnostics`. Its aggregate and +product statuses MUST use `ok`, `warning`, `error`, or `not_run`. + +REQ-PR-REGISTRYCTL-012: Doctor aggregation MUST redact local secret values from captured product +output. A report containing only `ok` or `warning` product statuses MUST exit successfully. A +report containing `error` or `not_run` MUST be emitted before the command exits with status `1`. +The [doctor tests](https://github.com/registrystack/registry-stack/blob/1a9c33d31f4d4db523da0d36011358200696e16f/crates/registryctl/src/lib.rs) +cover schema validation, redaction, and failure reporting. + +### 4.2 Smoke report + +`registryctl smoke` writes a `registryctl.smoke.v1` document to the configured local output +directory as `smoke-results.json`. The committed schema at +`crates/registryctl/schemas/registryctl.smoke.v1.schema.json` is the field-level contract. + +REQ-PR-REGISTRYCTL-013: A smoke report MUST contain `schema_version`, `base_url`, `passed`, and +`checks`. Each check MUST contain `name`, `method`, `path`, `expected_status`, `actual_status`, +`passed`, and `error`. + +REQ-PR-REGISTRYCTL-014: `registryctl smoke` MUST write the complete report when a check fails, then +exit with status `1`. The report MUST NOT contain local API keys or other values loaded from the +project secrets file. A report in which every check passes MUST set `passed` to `true` and exit +with status `0`. + +REQ-PR-REGISTRYCTL-015: Within the 1.x release line, a report MUST continue to satisfy the committed +schema identified by its `schema_version`. A release MUST NOT remove or rename a field, change a +field's type or meaning, make an optional field required, add a field where the schema closes +additional properties, or reuse a schema version for a different shape. + +## 5. Exit statuses + +Registryctl has three process exit statuses. Tests in `crates/registryctl/tests/exit_status.rs` +pin the status assigned to each class. + +| Status | Meaning | +| --- | --- | +| `0` | The requested command completed successfully. | +| `1` | A parsed command failed during validation, execution, or runtime interaction. | +| `2` | Command-line parsing failed because the command, subcommand, flag, or value was invalid or missing. | + +REQ-PR-REGISTRYCTL-016: Registryctl MUST return exactly `0`, `1`, or `2` according to this table. +A command MUST NOT return `0` when its machine-readable report has a failure status. + +The former hidden `init spreadsheet-api` alias is not part of the command surface. Invoking the +removed alias is a usage failure and returns status `2`. + +## 6. Explicitly unstable surfaces + +The following surfaces remain outside the 1.0 compatibility promise: + +- The generated layout of the local `init relay` Compose tutorial, including `registryctl.yaml`, + `compose.yaml`, and its data, state, secret, and product subdirectories. +- Directory names and intermediate files below a build root returned by + `registryctl.project_command.v1`. +- Editor staging, backup, and transaction artifacts that are not returned by a versioned report. +- Human-readable output text, whitespace, ordering, and terminal formatting. +- Hidden commands, worker processes, and their standard-input or standard-output protocols. +- Smoke check ordering and human-readable check names or error text. + +REQ-PR-REGISTRYCTL-017: Registryctl documentation MUST NOT present an unstable generated path as a +1.0 compatibility guarantee. Automation MUST use a versioned report, a committed schema, or a +separate public product contract to discover an output it consumes. + +## 7. Credential-issuance boundary + +Project authoring can compile evaluation-only Notary services without a credential profile. When +a project declares credential issuance, every selected claim must derive from an exact +compiler-pinned Registry Relay consultation. The compiler and end-to-end tests enforce this in +[`compiler/notary.rs`](https://github.com/registrystack/registry-stack/blob/1a9c33d31f4d4db523da0d36011358200696e16f/crates/registryctl/src/project_authoring/compiler/notary.rs) +and [`project_authoring.rs`](https://github.com/registrystack/registry-stack/blob/1a9c33d31f4d4db523da0d36011358200696e16f/crates/registryctl/tests/project_authoring.rs). + +REQ-PR-REGISTRYCTL-018: Registryctl MUST NOT compile a credential profile or OpenID for Verifiable +Credential Issuance (OID4VCI) configuration that selects a source-free claim. Source-free claims +MAY be used only for evaluation when no credential profile selects them. Credential issuance MUST +remain registry-backed. + +## Conformance + +A registryctl release conforms to this specification when it: + +- Preserves the authored project contract and validates it before compilation + (REQ-PR-REGISTRYCTL-002 through REQ-PR-REGISTRYCTL-005). +- Publishes deterministic complete outputs and exposes supported entry points through versioned + reports without stabilizing private internals (REQ-PR-REGISTRYCTL-006 through + REQ-PR-REGISTRYCTL-009). +- Emits compatible doctor and smoke reports without secret values (REQ-PR-REGISTRYCTL-010 through + REQ-PR-REGISTRYCTL-015). +- Returns the exact process status for success, command failure, and usage failure + (REQ-PR-REGISTRYCTL-016). +- Keeps unstable generated internals outside the compatibility promise + (REQ-PR-REGISTRYCTL-017). +- Refuses credential issuance from source-free claims (REQ-PR-REGISTRYCTL-018). + +## Evidence + +This specification is `verified`: the requirements describe implemented behavior with automated +tests. + +- `crates/registryctl/tests/init_output.rs` pins versioned initialization reports and supported + artifact entry points. +- `crates/registryctl/tests/project_authoring.rs` and inline project-authoring tests pin authored + path checks, staged initialization, deterministic generation, complete publication, and the + registry-backed issuance boundary. +- `crates/registry-config-report/tests/report_contract.rs` validates the doctor schema and its + canonical fixtures. +- Inline registryctl tests validate doctor aggregation, redaction, the smoke schema, failure-report + persistence, and secret absence. +- `crates/registryctl/tests/exit_status.rs` pins process statuses `0`, `1`, and `2`, including the + removed alias. + +## Next + +- [registryctl CLI reference](../../reference/registryctl/) lists the supported commands and flags. +- [API stability and versioning](../../reference/api-stability/) defines the stack-wide 1.0 + compatibility policy. +- [RS-PR-RELAY](../rs-pr-relay/) specifies the Registry Relay protocol compiled project inputs + configure. +- [RS-PR-NOTARY](../rs-pr-notary/) specifies the Registry Notary evaluation and issuance protocol. diff --git a/docs/site/src/content/docs/start/when-to-use.mdx b/docs/site/src/content/docs/start/when-to-use.mdx index b0b1b3330..310eb3202 100644 --- a/docs/site/src/content/docs/start/when-to-use.mdx +++ b/docs/site/src/content/docs/start/when-to-use.mdx @@ -39,8 +39,10 @@ These docs cover the two products you build against directly: - Registry Relay exposes existing source data (a file, extract, database, or legacy system) through protected, scoped, read-only HTTP routes, without giving callers database access. -- Registry Notary evaluates configured claims and returns a status, predicate, selected value, or - signed credential, instead of the full source record. +- Registry Notary evaluates configured evidence claims and returns a status, predicate, selected + value, or signed credential, instead of the full source record. An evidence consumer uses that + evidence, while the decision owner remains accountable for any resulting requirements, + decisions, or actions. {/* SVG diagram. Essential labels are restated in the bullets above and the sentence below. */}
@@ -92,6 +94,11 @@ Registry Stack deliberately leaves these to other systems: - It does not solve foundational identity, deduplication, legal authority, semantic governance, or institutional approval by itself. It makes those boundaries explicit so they can be reviewed before data moves. +- Registry Notary is not an eligibility, prioritization, workflow, or action engine. It evaluates + atomic, reusable evidence statements under evidence authorization and disclosure policy. The + evidence consumer determines how the evidence is used, and the decision owner retains + accountability for its rules and outcomes. Notary can attest a decision already made by an + authoritative source only when the claim identifies the decision as source-owned. - Registry Relay does not mutate source registry data: provisioning, inserts, updates, and write-back are out of scope for its consultation API. - Registry Relay is not an open-data portal: it publishes restricted consultation APIs for diff --git a/docs/site/src/content/docs/tutorials/author-registry-project.mdx b/docs/site/src/content/docs/tutorials/author-registry-project.mdx index 1a9d0a870..f37e86262 100644 --- a/docs/site/src/content/docs/tutorials/author-registry-project.mdx +++ b/docs/site/src/content/docs/tutorials/author-registry-project.mdx @@ -208,6 +208,34 @@ An attribute is caller-supplied context. Mapping an attribute does not authentic turn the value into an identifier. Registryctl rejects any other target path before generating Relay or Notary configuration. +## Apply the evidence-claim design test + +Keep three policy categories separate while you author the project. The integration and +environment define Relay source access and adaptation policy. The evidence service defines Notary +evidence authorization and disclosure policy. The evidence consumer and decision owner keep their +use, decision, workflow, and action policy outside the project. + +Test every proposed claim before adding it: + +1. Does the claim state one atomic evidence fact or value, rather than a consumer decision? +2. Does the claim define what `true`, `false`, `null`, `no_match`, `ambiguous`, and source failure + mean without turning missing evidence into a negative fact? +3. Can another authorized service or procedure reuse the evidence without importing the first + consumer's eligibility, qualification, ranking, workflow, or action rules? + +If any answer is no, narrow or rename the claim and keep the consuming decision downstream. +For example, prefer `programme-enrollment-active` or `measles-dose-recorded` to +`outreach-required` or `benefits-eligible`. A Common Expression Language (CEL) rule may derive a +precise evidence predicate from typed outputs, but it is not a general-purpose eligibility engine. +Across other sectors, prefer evidence such as `qualification-awarded`, +`business-registration-active`, `coverage-active`, `inspection-completed`, or +`professional-credential-valid` to outcomes such as `admission-approved`, `licence-granted`, +`claim-payable`, `inspection-priority`, or `access-permitted`. + +A claim may attest a decision that an authoritative source already made. In that exception, the +claim id and its review evidence must identify it as a source-owned decision, and the integration +must preserve the source's exact meaning. Notary does not recompute the decision. + ## Add another integration An existing project can add another protocol view over the same logical source without starting a @@ -221,7 +249,8 @@ second project or copying generated product configuration. Use a stable integrat `registry-stack.yaml`. 3. Under the consuming evidence service, add a consultation with `integration: benefit-record` and bind every integration input to one typed request path. Add - claims that select or derive from that consultation's declared outputs. + atomic evidence claims that select or derive from that consultation's declared outputs. Keep + consumer decisions outside the evidence service. 4. Add `integrations.benefit-record.source` to `environments/local.yaml` with its non-production origin, credential reference, and generation. Do not put credential values in the project files. diff --git a/docs/site/src/content/docs/tutorials/configure-project-script-adapter.mdx b/docs/site/src/content/docs/tutorials/configure-project-script-adapter.mdx index b2e83e009..dd3966276 100644 --- a/docs/site/src/content/docs/tutorials/configure-project-script-adapter.mdx +++ b/docs/site/src/content/docs/tutorials/configure-project-script-adapter.mdx @@ -29,6 +29,34 @@ evidence only; any reviewed source system can use the same script capability and +## Keep DHIS2 evidence separate from consumer action + +The bundled health-evidence journey follows one responsibility flow: + +1. DHIS2 remains the system of record. +2. Registry Relay performs bounded, same-origin DHIS2 acquisition and preserves the starter's + declared typed health outputs. The child-program and dose outputs use nullable booleans so an + unknown source value does not become a negative fact. +3. Registry Notary exposes those outputs as atomic evidence claims under evidence authorization + and disclosure policy. +4. In this example, a public-health programme is both the evidence consumer and decision owner. It + combines the evidence with its own rules and case state to decide whether outreach, follow-up, + or another action is required. + +Do not add claims such as `outreach-required` or `follow-up-priority` to the evidence service. +Those names describe consumer decisions. A source-owned decision is the exception: a +claim can attest it only when the claim id and its review evidence identify the decision as +source-owned. + +The adapter and fixtures must preserve source meaning. For a matched subject, a completed DHIS2 +programme-stage event maps to `true`, an existing non-completed event maps to `false`, and an +absent enrollment or stage maps to `null`. A `404` maps to `no_match`, not to negative health +evidence. Source rejection and subject mismatch are failures and produce no claims. Ambiguity is +explicitly not applicable to this singleton DHIS2 resource; another adapter must prove ambiguity +or handle it separately. Missing evidence is not a negative fact. + +{/* Evidence: crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/. */} + ## Declare source authority The public integration declares input schemas, authentication interface, allowed source paths, @@ -194,6 +222,10 @@ registryctl check --project-dir --environment --explain Offline tests are portable and use no real credentials or network. Production activation remains Linux-only while the process controls are code-owned. Fixture success proves the adapter against synthetic observations, not production source interoperability or deployment approval. +Keep the offline fixtures as the deterministic acceptance path. A live DHIS2 compatibility check +is optional and requires a reachable instance, dedicated credentials, reviewed source authority, +and separate evidence recording. Public demo availability does not determine whether the project +passes its offline acceptance checks. ## Troubleshooting diff --git a/docs/site/src/content/docs/tutorials/configure-project-snapshot-materialization.mdx b/docs/site/src/content/docs/tutorials/configure-project-snapshot-materialization.mdx index 7e435c05a..4d3c76cf6 100644 --- a/docs/site/src/content/docs/tutorials/configure-project-snapshot-materialization.mdx +++ b/docs/site/src/content/docs/tutorials/configure-project-snapshot-materialization.mdx @@ -64,11 +64,11 @@ primary_key: person_id schema: type: object additionalProperties: false - required: [person_id, registration_status, eligible] + required: [person_id, registration_status, residency_confirmed] properties: person_id: { type: string, maxLength: 64 } registration_status: { type: [string, "null"], maxLength: 32 } - eligible: { type: [boolean, "null"] } + residency_confirmed: { type: [boolean, "null"] } materialization: max_records: 1000000 max_bytes: 256MiB @@ -100,7 +100,7 @@ services: metadata: people:metadata rows: people:rows purposes: [case-management] - projection: [person_id, registration_status, eligible] + projection: [person_id, registration_status, residency_confirmed] pagination: { default_limit: 50, max_limit: 100 } filters: person_id: [eq] @@ -133,7 +133,7 @@ capability: exact: person_id: { input: person_id } freshness: 24h -outputs: [registration_status, eligible] +outputs: [registration_status, residency_confirmed] ``` The `snapshot` capability applies the complete exact selector to one immutable published handle and probes at @@ -144,6 +144,16 @@ Add an evidence service and consultation in `registry-stack.yaml`, mapping `pers declared request identifier. This Notary-owned service defines its service policy, claims, disclosure, and optional credential profiles. Claims can select only the integration's closed outputs through their authored `output` field. +Name those claims for reusable evidence, such as `population-registration-status` and +`residency-confirmed`, rather than for a consumer decision such as `benefits-eligible`. The +evidence consumer determines how the evidence is used, and the decision owner retains +accountability for eligibility and action rules. + +Preserve the distinction between an explicit `false`, a matched `null`, `no_match`, `ambiguous`, +and an unavailable or stale materialization. Missing evidence is not a negative fact, so a claim +must not turn those non-values or failures into `false`. An explicitly named record-exists claim +can map `matched == false` to `false`; the residency evidence remains `null`, and ambiguity or +source failure remains unavailable. ## Bind the physical source once @@ -159,7 +169,7 @@ entities: columns: person_id: subject_key registration_status: status_code - eligible: benefit_eligible + residency_confirmed: residency_flag source_revision: population-export-v1 generation: 2026-07-12 ``` diff --git a/docs/site/src/content/docs/tutorials/first-run-with-solmara-lab.mdx b/docs/site/src/content/docs/tutorials/first-run-with-solmara-lab.mdx index ad0ec17e9..67b204d1e 100644 --- a/docs/site/src/content/docs/tutorials/first-run-with-solmara-lab.mdx +++ b/docs/site/src/content/docs/tutorials/first-run-with-solmara-lab.mdx @@ -38,10 +38,14 @@ Do not use the generated local keys in production. For a standalone local project generated from your own data, use the `registryctl` tutorials: [run a protected registry API](../publish-spreadsheet-secured-registry-api/) or [evaluate a claim with Registry Notary](../verify-claim-registry-api/). Clone Solmara Lab when you -want the full multi-service country demo. The hosted path remains visible for inspection, but its -credential continuation is held by [GH#330](https://github.com/registrystack/registry-stack/issues/330) -and the incomplete [GH#198](https://github.com/registrystack/registry-stack/issues/198) -fresh-reader run. +want the full multi-service country demo. The planned final 1.0 release-candidate reader run in +[GH#198](https://github.com/registrystack/registry-stack/issues/198) requires a tagged Registry +Stack candidate and a Solmara Lab release that atomically pins the matching `registryctl`, Registry +Relay, Registry Notary, and image digests. Where Registry Notary applies, the run must also prove +one Registry Notary instance and one Notary-owned PostgreSQL database per Registry Relay authority, +including restart persistence. +{/* TODO[evidence]: Replace this issue-tracked plan with a stable public run artifact and + immutable Solmara Lab release link before presenting the gate as completed evidence. */} ## Prerequisites diff --git a/docs/site/src/data/generated/project-authoring-journeys.json b/docs/site/src/data/generated/project-authoring-journeys.json index a9ec48403..04f100bbc 100644 --- a/docs/site/src/data/generated/project-authoring-journeys.json +++ b/docs/site/src/data/generated/project-authoring-journeys.json @@ -45,10 +45,10 @@ "build" ], "integration": "eligibility", - "fixture": "eligible-household", + "fixture": "source-approved-household", "commands": [ - "registryctl test --project-dir crates/registryctl/tests/fixtures/project-authoring/custom-system --integration eligibility --fixture eligible-household --trace", - "registryctl test --project-dir crates/registryctl/tests/fixtures/project-authoring/custom-system --integration eligibility --fixture eligible-household --watch", + "registryctl test --project-dir crates/registryctl/tests/fixtures/project-authoring/custom-system --integration eligibility --fixture source-approved-household --trace", + "registryctl test --project-dir crates/registryctl/tests/fixtures/project-authoring/custom-system --integration eligibility --fixture source-approved-household --watch", "registryctl test --project-dir crates/registryctl/tests/fixtures/project-authoring/custom-system", "registryctl check --project-dir crates/registryctl/tests/fixtures/project-authoring/custom-system --environment local --explain", "registryctl build --project-dir crates/registryctl/tests/fixtures/project-authoring/custom-system --environment local" @@ -76,7 +76,7 @@ { "id": "dhis2-tracker", "label": "DHIS2 Tracker", - "summary": "A product-neutral script adapter applied to a bounded DHIS2 Tracker read journey.", + "summary": "Bounded DHIS2 Tracker acquisition normalized into reusable health evidence for consumer-owned decisions.", "source": "crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker", "classification": "maintained", "topology": "combined", @@ -92,12 +92,12 @@ "build" ], "integration": "health-record", - "fixture": "complete-health-match", + "fixture": "complete-child-health-evidence", "commands": [ "registryctl init --from dhis2-tracker --project-dir dhis2-project", "registryctl authoring editor --project-dir dhis2-project", - "registryctl test --project-dir dhis2-project --integration health-record --fixture complete-health-match --trace", - "registryctl test --project-dir dhis2-project --integration health-record --fixture complete-health-match --watch", + "registryctl test --project-dir dhis2-project --integration health-record --fixture complete-child-health-evidence --trace", + "registryctl test --project-dir dhis2-project --integration health-record --fixture complete-child-health-evidence --watch", "registryctl test --project-dir dhis2-project", "registryctl check --project-dir dhis2-project --environment local --explain", "registryctl build --project-dir dhis2-project --environment local" diff --git a/docs/site/src/data/generated/project-starters.json b/docs/site/src/data/generated/project-starters.json index 3d778655d..b9730c018 100644 --- a/docs/site/src/data/generated/project-starters.json +++ b/docs/site/src/data/generated/project-starters.json @@ -32,7 +32,7 @@ { "id": "dhis2-tracker", "label": "DHIS2 Tracker", - "summary": "A product-neutral script adapter applied to a bounded DHIS2 Tracker read journey.", + "summary": "Bounded DHIS2 Tracker acquisition normalized into reusable health evidence for consumer-owned decisions.", "source": "crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker", "classification": "maintained", "topology": "combined", @@ -48,12 +48,12 @@ "build" ], "integration": "health-record", - "fixture": "complete-health-match", + "fixture": "complete-child-health-evidence", "commands": [ "registryctl init --from dhis2-tracker --project-dir dhis2-project", "registryctl authoring editor --project-dir dhis2-project", - "registryctl test --project-dir dhis2-project --integration health-record --fixture complete-health-match --trace", - "registryctl test --project-dir dhis2-project --integration health-record --fixture complete-health-match --watch", + "registryctl test --project-dir dhis2-project --integration health-record --fixture complete-child-health-evidence --trace", + "registryctl test --project-dir dhis2-project --integration health-record --fixture complete-child-health-evidence --watch", "registryctl test --project-dir dhis2-project", "registryctl check --project-dir dhis2-project --environment local --explain", "registryctl build --project-dir dhis2-project --environment local" diff --git a/products/notary/CHANGELOG.md b/products/notary/CHANGELOG.md index dcafbe3b4..2dd5909bf 100644 --- a/products/notary/CHANGELOG.md +++ b/products/notary/CHANGELOG.md @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- BREAKING: The closed Relay-result decoder now matches the reduced pre-1.0 + `registry.relay.consultation-result.v1` contract and rejects the retired + `provenance.consent` member as unknown. Registry-backed issuance still + requires compiler-pinned Relay evidence; source-free declarations remain + evaluation-only and cannot be issued as credentials. Consent verification, + policy enforcement, legal-basis handling, and authorization are unchanged. +- BREAKING: Replace deployment-waiver `reason` with a required validated + `reference` and an optional validated `summary`. Strict configuration parsing + rejects the retired field. Restricted posture and boot warnings expose only + the new metadata, default posture continues to omit waiver metadata, and + hard startup/readiness gates remain non-waivable. + ## [0.12.2] - 2026-07-20 - No new Notary product features or public API contract changes. This release diff --git a/products/notary/README.md b/products/notary/README.md index 243c8a17b..1bb7d8f2c 100644 --- a/products/notary/README.md +++ b/products/notary/README.md @@ -24,6 +24,13 @@ service policy, claim evaluation, disclosure, credential issuance, and its own audit chain. Relay keeps independent authority over source acquisition, normalization, protocol verification, typed outputs, and its audit chain. +The evidence consumer determines how returned evidence is used. The decision +owner remains accountable for requirements, eligibility, qualification, +prioritization, approval, referral, payment, workflow, and action policy. +Notary may attest a decision that an authoritative source already made when +the claim is named and documented as source-owned, but Notary does not +recompute consumer policy. + See [`docs/README.md`](docs/README.md) for product documentation. Use Registry Stack project authoring and `registryctl` to generate deployable Relay and Notary inputs. Do not hand-author source access inside Notary. diff --git a/products/notary/docs/README.md b/products/notary/docs/README.md index 3c8eaa989..05420a4d6 100644 --- a/products/notary/docs/README.md +++ b/products/notary/docs/README.md @@ -43,5 +43,7 @@ every selected claim. It never connects directly to a registry source. Registry source adaptation belongs to Relay and Registry Stack project authoring. Rhai is Relay's reviewed `script` capability and CEL is Notary's -claim policy language. Product and version metadata never selects either -runtime. +evidence-claim policy language. Product and version metadata never selects +either runtime. The evidence consumer determines how returned evidence is +used, and the decision owner remains accountable for requirements, decisions, +workflow, and actions. CEL is not a general-purpose consumer decision engine. diff --git a/products/notary/docs/architecture-overview.md b/products/notary/docs/architecture-overview.md index 03797638c..93710ca87 100644 --- a/products/notary/docs/architecture-overview.md +++ b/products/notary/docs/architecture-overview.md @@ -28,18 +28,21 @@ verifies their full semantic contract before serving. before source access. 4. Relay returns `match`, `no_match`, or `ambiguous`, plus typed minimized outputs only on `match` and closed acquisition provenance. -5. Notary derives direct and CEL claims, applies disclosure, and either returns - the result or issues an allowed credential. -6. Each product writes its own redacted audit record. The evaluation id and +5. Notary derives direct and CEL evidence claims, applies disclosure, and + either returns the result or issues an allowed credential. +6. The evidence consumer determines how the returned evidence is used. The + decision owner applies any requirement, decision, workflow, or action + policy. +7. Each product writes its own redacted audit record. The evaluation id and consultation id support restricted cross-service reconciliation. ```mermaid flowchart LR - Caller[Caller] --> Notary[Registry Notary
policy, claims, disclosure, issuance] + Consumer[Evidence consumer
use, decision input, action] --> Notary[Registry Notary
evidence, disclosure, issuance] Notary --> Relay[Registry Relay
acquisition, normalization, outputs] Relay --> Source[(Registry source)] Relay --> Notary - Notary --> Caller + Notary --> Consumer ``` Notary never treats a Relay failure as `no_match`. Ambiguity, denial, source, @@ -50,14 +53,21 @@ consultation group. Raw Relay errors are not exposed as claim values. `self_attested` evidence performs no Relay or source I/O. Its rules and dependency closure must remain source-free. Delegated authorization is a -separate Notary policy decision. Where a delegated relationship is proved by -Relay, Notary still pins the exact consultation and performs all authorization -checks before invoking it. +separate Notary authorization decision, not a consumer decision. Where a +delegated relationship is proved by Relay, Notary still pins the exact +consultation and performs all authorization checks before invoking it. ## Product boundaries Relay owns source authentication, network policy, HTTP and protocol helpers, Rhai source adaptation, typed outputs, and acquisition provenance. Notary owns -caller authentication, purpose and legal basis, consent policy, claims, CEL, -disclosure, credential profiles, signing, and issuance. Their state and audit -authority remain separate. +caller authentication, purpose and legal basis, consent policy, reusable +evidence claims, CEL evidence derivation, disclosure, credential profiles, +signing, and issuance. The evidence consumer determines how returned evidence is +used, and the decision owner remains accountable for requirements, eligibility, +qualification, prioritization, approval, referral, payment, workflow, and +action policy. Their state and audit authority remain separate. + +Notary may attest a decision that an authoritative source has already made. +That claim is a source-owned decision and must be named and documented as such; +Notary does not recompute it as consumer policy. diff --git a/products/notary/docs/client-sdk-guide.md b/products/notary/docs/client-sdk-guide.md index 21726635d..d68c5749d 100644 --- a/products/notary/docs/client-sdk-guide.md +++ b/products/notary/docs/client-sdk-guide.md @@ -484,6 +484,10 @@ Use the canonical request shape when the high-level helper hides fields your workflow cares about. The Python and Node raw helpers preserve canonical snake_case JSON. +In these examples, `benefits_eligibility` identifies the authorized purpose +for evidence access and audit. It does not define the evidence consumer's +eligibility rule or transfer decision ownership to Notary. + ```python result = client.evaluate_request( { diff --git a/products/notary/docs/notary-capability-matrix.md b/products/notary/docs/notary-capability-matrix.md index b150d0ed5..6a5938705 100644 --- a/products/notary/docs/notary-capability-matrix.md +++ b/products/notary/docs/notary-capability-matrix.md @@ -29,9 +29,9 @@ scenario patterns](notary-scenario-patterns.md). | --- | --- | --- | | Citizen or resident | Share only the proof needed to access a service | Parent applying for child support, farmer applying for a voucher | | Case worker | Make an evidence-backed decision without seeing unnecessary registry data | Benefits officer, enrollment officer | -| Program administrator | Define eligibility policy, evidence requirements, and acceptable issuers | Social protection ministry, agriculture program team | +| Service or procedure owner | Define requirements, decision policy, and acceptable issuers | Social protection ministry, university admissions office, licensing authority | | Registry steward | Protect source registry data while answering authorized evidence questions | Civil registry, farmer registry, health facility registry | -| Auditor or oversight body | Verify decisions and data exchanges were lawful, minimized, and replay-protected | Internal audit, data protection authority | +| Auditor or oversight body | Verify evidence evaluations and data exchanges were lawful, minimized, and replay-protected | Internal audit, data protection authority | | Wallet or client app operator | Help users present proofs or receive credentials | Mobile wallet, service portal, case-management app | ## Systems @@ -40,39 +40,45 @@ scenario patterns](notary-scenario-patterns.md). | --- | --- | | Source registry | Operational system of record. It is not exposed directly to consumers | | Registry Relay | Read-only gateway and metadata publisher for source registry data | -| Registry Notary | Evaluates Relay-backed or source-free claims, signs results, issues credentials only from exact Relay-backed evaluation provenance, enforces evidence policy, and emits audit. Source-free results cannot authorize issuance | +| Registry Notary | Evaluates reusable Relay-backed or source-free evidence claims, signs results, issues credentials only from exact Relay-backed evaluation provenance, enforces evidence policy, and emits audit. Source-free results cannot authorize issuance | | Registry Manifest | Public metadata and discovery artifact for capabilities, profiles, and evidence offerings | | Registry Platform | Shared crypto, HTTP, OIDC, SD-JWT, DID/JWK, replay, and audit primitives | -| Service portal or case system | Starts a service workflow and consumes evidence or decisions | +| Service portal or case system | Acts as an evidence consumer; the operating institution remains the decision owner | | Holder wallet or client app | Stores credentials, presents proofs, and receives issued credentials | | Trust bundle or trust registry | Signed trust metadata; not yet supported. Peer trust today is a static allowlist | | Audit store | Local audit trail for evaluations, issuance, denials, and federation exchanges | ## Scenario matrix +The supported scenarios return evidence. The evidence consumer determines how +the evidence is used, and the decision owner remains accountable for any +eligibility, qualification, entitlement, payment, referral, workflow, or +action decision. A Notary claim may attest a source-owned decision, but Notary +does not recompute that decision as consumer policy. + | # | Scenario | Pattern | Status | Main gap | | --- | --- | --- | --- | --- | | 1 | Civil alive predicate | Relay-backed evaluation | Supported | None for a compiler-pinned Relay consultation | | 2 | Age or date-of-birth evidence | Relay-backed evaluation | Supported | None for a compiler-pinned Relay consultation | -| 3 | Program enrollment active | Relay-backed evaluation | Supported | None for a compiler-pinned Relay consultation | +| 3 | Programme enrollment active | Relay-backed evaluation | Supported | None for a compiler-pinned Relay consultation | | 4 | Health facility service available | Relay-backed evaluation | Supported | None for a compiler-pinned Relay consultation | -| 5 | Agriculture voucher eligibility | Relay-backed evaluation | Supported | None for a compiler-pinned Relay consultation | -| 6 | Livestock movement permit eligibility | Relay-backed evaluation | Supported | None for a compiler-pinned Relay consultation | +| 5 | Farmer registration and landholding evidence | Relay-backed evaluation | Supported | None for a compiler-pinned Relay consultation | +| 6 | Livestock identity and movement-status evidence | Relay-backed evaluation | Supported | None for a compiler-pinned Relay consultation | | 7 | Benefits agency asks Civil Notary for alive predicate | Delegated evaluation | Planned | Inbound federation is source-free in this version, and no outbound Notary client ships | | 8 | Benefits agency asks Social Notary for active beneficiary predicate | Delegated evaluation | Planned | Inbound federation is source-free in this version, and no outbound Notary client ships | | 9 | Health-linked child support across civil, social, and health | Outbound composition | Planned | No outbound Notary client or peer-result composition runtime ships yet; you cannot compose signed peer results across authorities in one flow | | 10 | Municipality verifies residency with a national registry steward | Delegated evaluation | Planned | Inbound federation is source-free in this version, and no outbound client or demo wiring ships | | 11 | Citizen presents civil-status proof to a benefits service | User-presented proof | Planned | No proof profiles or verifier runtime ship yet; you cannot accept this user-presented civil-status proof | | 12 | Farmer presents landholding or farmer-registration proof | User-presented proof | Planned | No proof profiles or status/freshness policy ship yet; you cannot accept a user-presented landholding or farmer-registration proof | -| 13 | Health worker presents professional credential for service eligibility | User-presented proof | Planned | No proof profiles or issuer trust policy ship yet; you cannot accept a presented professional credential for this eligibility check | +| 13 | Health worker presents professional credential for workforce assignment | User-presented proof | Planned | No proof profiles or issuer trust policy ship yet; you cannot accept the presented professional evidence | | 14 | Parent or guardian requests a service for a child or dependent | Delegated self-attestation plus proof | Supported | Evaluation and rendering require the configured Relay-backed relationship proof; delegated credential issuance is intentionally unavailable in 1.0 | | 15 | Household or group representative requests a service | Representation plus proof | Planned | No collective subject model or representative authority policy ships yet; you cannot let a household or group representative request this service | | 16 | Civil Notary issues date-of-birth or alive credential | Credential issuance | Supported | Full EdDSA and ES256 source-tested pre-authorized flow exists; external wallet evidence remains candidate-only | -| 17 | Agriculture Notary issues voucher eligibility credential | Credential issuance | Supported | Full EdDSA and ES256 source-tested pre-authorized flow exists; external wallet evidence remains candidate-only | -| 18 | Shared Eligibility Notary issues combined-support credential | Credential issuance plus composition | Partial | Relay-backed credential issuance exists, but peer-result composition is missing | +| 17 | Agriculture Notary issues farmer-registration or landholding credential | Credential issuance | Supported | Full EdDSA and ES256 source-tested pre-authorized flow exists; external wallet evidence remains candidate-only | +| 18 | Shared evidence service issues combined-support evidence credential | Credential issuance plus composition | Partial | Relay-backed credential issuance exists, but peer-result composition is missing | | 19 | Consuming service helps holder obtain credential from remote Notary | Federated credential issuance | Planned | No holder-binding ceremony, nonce ownership, or relay rules ship yet; you cannot help a holder obtain a credential from a remote Notary through this service | | 20 | Replay and emergency peer/key denial | Governance | Supported | Active-active deployments require the typed Notary-owned PostgreSQL state schema | -| 21 | Auditor verifies minimized decision evidence | Governance | Partial | Signed results and audit exist, checkpoints are planned | +| 21 | Auditor verifies minimized evidence exchange | Governance | Partial | Signed results and audit exist, checkpoints are planned | | 22 | Peer audit checkpoint monitoring | Governance | Planned | No checkpoint publisher, Merkle builder, or peer monitor ships yet; you cannot independently verify peer audit checkpoints | Each Relay authority uses one Notary authority, with Notary-owned PostgreSQL diff --git a/products/notary/docs/notary-scenario-patterns.md b/products/notary/docs/notary-scenario-patterns.md index d1b35a2d3..a727ce5a7 100644 --- a/products/notary/docs/notary-scenario-patterns.md +++ b/products/notary/docs/notary-scenario-patterns.md @@ -4,23 +4,26 @@ ```mermaid sequenceDiagram - participant App as Service application + participant Consumer as Evidence consumer participant Notary as Registry Notary participant Relay as Registry Relay participant Source as Registry source - App->>Notary: Evaluate claims for purpose + Consumer->>Notary: Evaluate evidence claims for purpose Notary->>Notary: Authenticate and authorize Notary->>Relay: Execute pinned consultation Relay->>Source: Governed read Source-->>Relay: Bounded response Relay-->>Notary: Outcome, outputs, provenance Notary->>Notary: Claims, disclosure, issuance policy - Notary-->>App: Minimized result or credential + Notary-->>Consumer: Minimized evidence or credential + Consumer->>Consumer: Use evidence under applicable policy ``` -One consultation can support several direct and CEL claims. Relay returns -typed outputs, while Notary owns claim meaning and disclosure. +One consultation can support several direct and CEL evidence claims. Relay +returns typed outputs, Notary owns evidence meaning and disclosure, and the +evidence consumer determines how the evidence is used. The decision owner +remains accountable for requirements, decisions, workflow, and actions. ## Self-attested Notary-only evaluation @@ -31,11 +34,13 @@ sequenceDiagram Holder->>Notary: Source-free evidence request Notary->>Notary: Validate token and subject binding - Notary->>Notary: Evaluate self-attested CEL policy + Notary->>Notary: Evaluate allowed self-attested evidence claim Notary-->>Holder: Allowed result or credential ``` -This topology performs no Relay or registry-source call. +This topology performs no Relay or registry-source call. The identity token +authorizes subject-bound access; it does not establish consumer eligibility +or another consumer-owned outcome. ## Delegated evaluation diff --git a/products/notary/docs/operator-config-reference.md b/products/notary/docs/operator-config-reference.md index 14b113f70..b447b227b 100644 --- a/products/notary/docs/operator-config-reference.md +++ b/products/notary/docs/operator-config-reference.md @@ -210,7 +210,8 @@ deployment.waivers deployment.waivers[] deployment.waivers[].expires deployment.waivers[].finding -deployment.waivers[].reason +deployment.waivers[].reference +deployment.waivers[].summary evidence evidence.allowed_purposes evidence.allowed_purposes[] @@ -723,6 +724,34 @@ authenticity boundary. ## State and operations +Deployment waivers name one finding, a required operator reference, and a mandatory expiry date. +The reference is 1 to 128 bytes, has no surrounding whitespace, uses only letters, digits, `.`, +`_`, `:`, and `-`, and cannot contain `..`. +References cannot start, case-insensitively, with `Bearer:` or `Basic:`, directly or +after `Authorization:`. +Use a ticket-style reference such as `OPS-2026-0042`. +An optional summary is 1 to 256 Unicode characters, already trimmed, contains no control +characters, and cannot be an authorization value or contain a private-key begin marker. +Omit `summary` when it is not needed; explicit `null` is invalid. +Keep credentials and private keys out of both fields. +These rules implement +[RS-OP-POSTURE](https://docs.registrystack.org/spec/rs-op-posture/) (REQ-OP-POSTURE-011). + +```yaml +deployment: + profile: hosted_lab + waivers: + - finding: notary.openapi.public + reference: OPS-2026-0042 + summary: Public API catalog is approved for the lab + expires: 2026-09-30 +``` + +Expired waivers stop suppressing their finding and raise `deployment.waiver_expired`. +`startup_fail` and `readiness_fail` gates are not waivable. The default posture tier omits waiver +metadata; the restricted tier can report only the validated reference, optional summary, and +expiry. Boot warnings use the same metadata. Audit records remain minimized to finding IDs. + Use the typed Notary-owned PostgreSQL state schema for multi-instance or production deployments. The schema holds replay, nonce, evaluation, idempotency, credential-status, quota, and preauthorization state. Explicit diff --git a/products/notary/docs/self-attestation-operator-guide.md b/products/notary/docs/self-attestation-operator-guide.md index a52346be1..81f37dc36 100644 --- a/products/notary/docs/self-attestation-operator-guide.md +++ b/products/notary/docs/self-attestation-operator-guide.md @@ -57,12 +57,18 @@ caller-supplied identity context is rejected before claim evaluation. Use self-attestation when: -- A citizen portal evaluates eligibility from the citizen's own token. +- A citizen portal evaluates allowed evidence claims for the token-bound + subject before the evidence consumer applies its own eligibility or + decision policy. - A wallet flow issues a credential for the token-bound subject from a compiler-pinned Relay-backed evaluation. - The identity provider can provide a stable, reviewed subject-binding claim. - The evidence service accepts token-bound subject access for the configured purpose. +Self-attestation authorizes subject-bound access to configured evidence. The +identity token is not evidence of consumer eligibility, and Registry Notary +does not turn it into a consumer-owned outcome. + Do not use it when: - The token has no trustworthy subject identifier. @@ -327,7 +333,11 @@ Confirm that: - claim and request purposes are stable and auditable; - caller scopes, client ids, audiences, formats, disclosures, and any registry-backed credential profiles are narrowly allow-listed; and -- the evidence service does not present self-attestation as registry-verified evidence. +- the evidence service does not present self-attestation as registry-verified evidence; and +- the evidence consumer determines how evidence is used, and the decision + owner, not Registry Notary, remains accountable for eligibility, + qualification, entitlement, prioritization, approval, referral, payment, + workflow, and action decisions. Use [`source-claim-modeling-guide.md`](source-claim-modeling-guide.md) to review the evidence boundary. diff --git a/products/notary/docs/source-claim-modeling-guide.md b/products/notary/docs/source-claim-modeling-guide.md index 827984293..cbd4cd934 100644 --- a/products/notary/docs/source-claim-modeling-guide.md +++ b/products/notary/docs/source-claim-modeling-guide.md @@ -1,7 +1,7 @@ # Source and claim modeling guide -Use this guide to keep source adaptation in Relay and evidence policy in -Notary. +Use this guide to keep source adaptation in Relay, reusable evidence in +Notary, and consumer decisions in the consuming system. ## Choose the topology @@ -15,6 +15,38 @@ A Registry Stack project has one registry trust domain and one logical source available to Relay. Separate independent registries require separate projects. Do not join them inside one Notary claim. +## Keep evidence separate from consumer decisions + +This evidence-versus-decision boundary is normative for 1.0 project +authoring. Use it for every registry-backed flow: + +```text +Source system + -> Registry Relay: source-specific acquisition and typed normalization + -> Registry Notary: atomic, precise, reusable evidence statements + -> Evidence consumer: use evidence under consumer-owned policy + -> Decision owner: remain accountable for requirements and outcomes +``` + +The caller, evidence consumer, and decision owner can be the same component or +separate components. The caller is the technical client that invokes Notary. +The evidence consumer uses the returned evidence. The decision owner is the +institution accountable for the requirements, rules, decisions, and actions. + +The products and consuming roles have different responsibilities: + +| Role | Responsibility | +| --- | --- | +| Registry Relay | Source access, bounded acquisition, and source-specific adaptation | +| Registry Notary | Evidence meaning, caller authorization, disclosure, and credential issuance | +| Evidence consumer | Evidence use, presentation, routing, and workflow integration | +| Decision owner | Requirements, eligibility, qualification, prioritization, approval, referral, payment, and action | + +Registry Notary may attest a decision already made by an authoritative source. +Name and document that claim as a source-owned decision, such as +`social-registry-assessed-eligible`. Do not present it as a decision computed +by Registry Notary. + ## Model the Relay integration Author an integration with one product-neutral capability: @@ -32,24 +64,51 @@ only the operational overrides the integration needs. Authentication, destinations, private networks, trust roots, and secrets belong to the private environment binding. +Relay outputs describe the source response in a stable typed form. They do not +encode an evidence consumer's decision or action rules. + ## Model the Notary service An evidence service owns purpose, legal basis, consent policy, caller access, consultations, claims, disclosure, and credential profiles. A consultation is one named use of a Relay integration. It may feed several direct or CEL claims. -Use a direct output claim for a single Relay output. Use CEL for -purpose-specific predicates or derived values. CEL is not a source adapter and -cannot perform I/O. Credential profile membership has one authored direction: -the profile lists its claims. +Use a direct output claim for a single Relay output. Use CEL for evidence +predicates or derived evidence values. CEL is not a source adapter, cannot +perform I/O, and is not a general-purpose consumer eligibility or decision +engine. +Credential profile membership has one authored direction: the profile lists +its claims. + +A claim can be evaluated under purpose-bound authorization while retaining +evidence semantics that several services or procedures can reuse. The evidence +consumer determines how the claims are used after Notary returns them, while +the decision owner remains accountable for any resulting outcome. + +## Test each claim design + +Before accepting a claim, confirm that: + +- The claim states evidence, not an entitlement, payment, referral, outreach, + or workflow action. +- Its `true`, `false`, `null`, and unavailable cases have reviewed meanings. +- Another service or procedure can reuse the statement without importing the + first consumer's decision rules. +- A claim that reports an authoritative source's decision is named and + documented as source-owned. ## Preserve failure semantics Treat `no_match` as an explicit consultation outcome. Do not model presence as an author-declared output. Ambiguity and Relay failures abort the consultation group. A credential containing a direct output claim is not issuable on -`no_match`; a reviewed predicate may explicitly turn `matched == false` into -`false` when its profile allows that predicate. +`no_match`. Do not turn missing evidence into a negative fact merely to produce +a boolean. A boolean evidence claim can return `false` from a matched source +outcome whose declared typed outputs establish the negative fact. An explicitly +named existence predicate may also map `matched == false` to `false` when its +reviewed meaning is exactly whether one admissible match exists. This exception +does not make other claims negative, and ambiguity or failure remains +unavailable. ## Review checklist @@ -58,6 +117,7 @@ group. A credential containing a direct output claim is not issuable on - The compiler-produced semantic contract and hash are pinned exactly. - Inputs map only from the closed request grammar. - Outputs are typed, bounded, minimized, and distinct from claims. +- Claims pass the evidence-versus-decision design test. - One consultation is reused for related claims within an evaluation. - No raw Relay error becomes a claim. - Fixtures cover match, no-match, ambiguity, mismatch where applicable, and diff --git a/products/notary/openapi/registry-notary.openapi.json b/products/notary/openapi/registry-notary.openapi.json index 2eb25cef2..b154e8279 100644 --- a/products/notary/openapi/registry-notary.openapi.json +++ b/products/notary/openapi/registry-notary.openapi.json @@ -358,6 +358,13 @@ "provenance": { "$ref": "#/components/schemas/ClaimProvenance" }, + "redacted_fields": { + "description": "Claim or top-level field names redacted from the public result. Omitted when no redaction was applied.", + "items": { + "type": "string" + }, + "type": "array" + }, "requester_ref": { "$ref": "#/components/schemas/EvidenceEntityRef" }, diff --git a/products/platform/CHANGELOG.md b/products/platform/CHANGELOG.md index eb401cea6..dc6ec9124 100644 --- a/products/platform/CHANGELOG.md +++ b/products/platform/CHANGELOG.md @@ -2,6 +2,15 @@ ## Unreleased +- BREAKING: `registry-platform-ops` replaces deployment-waiver `reason` with + required `reference` plus optional omitted `summary` in the shared posture + contract. It now owns the common 128-byte reference and 256-character summary + validation authority used by Relay and Notary. The default posture allowlist + continues to exclude all waiver metadata. Relay, Notary, and posture schemas + now share the portable structural reference and summary constraints; + contextual credential and private-key marker checks remain in the shared + semantic validator. + ## v0.12.2 - 2026-07-20 - No user-visible Registry Platform changes. This release fixes forward from diff --git a/release/conformance/integrations/README.md b/release/conformance/integrations/README.md index 71584a0bd..2196acac0 100644 --- a/release/conformance/integrations/README.md +++ b/release/conformance/integrations/README.md @@ -27,8 +27,14 @@ The current starter's synthetic route is not evidence that the real operation is compatible. For DHIS2, the instance owner must attest every metadata UID used by the -authored adapter. The `DEMO_*` values in the starter are examples and cannot -appear in a live evidence project. +authored adapter, including the child programme and BCG, OPV, and measles +programme-stage UIDs. The `DEMO_*` values in the starter are examples and +cannot appear in a live evidence project. + +The DHIS2 starter's offline fixtures are the deterministic acceptance path for +claim semantics and failure behavior. Record live public-demo or operator-owned +compatibility only through this release evidence flow. Public demo uptime, +credentials, and mutable records are not offline acceptance dependencies. Do not simulate either prerequisite. Do not convert fixture output, a dry run, or application-only logs into candidate evidence. @@ -163,6 +169,12 @@ non-closing evidence. The validator accepts `status: passed` only when every applicable case and teardown is recorded as passed; maintainer review still establishes whether the recorded hashes correspond to the restricted evidence. +After an independent operator completes the frozen journey, use +[`pilot-report.template.md`](pilot-report.template.md) to publish the bounded +outcome, findings, and public triage links without copying restricted evidence +into the repository. A plan or dry run is not evidence, and one pilot is not +proof of broad production readiness. + ## Review the source packet - [`profiles/opencrvs-dci-v1.9.profile.json`](profiles/opencrvs-dci-v1.9.profile.json) diff --git a/release/conformance/integrations/pilot-report.template.md b/release/conformance/integrations/pilot-report.template.md new file mode 100644 index 000000000..7af8719e6 --- /dev/null +++ b/release/conformance/integrations/pilot-report.template.md @@ -0,0 +1,84 @@ +# External integration pilot report + +Use this template only after an independent operator has completed the frozen +pilot journey. Publish a concise account of the outcome and link the validated, +sanitized result. Do not include credentials, network origins, operator or +source identifiers, record identifiers, raw audits, private evidence, or links +to restricted evidence. + +Plans, dry runs, fixture runs, and source-built branch runs are not pilot +evidence. One completed pilot is not proof of broad production readiness. + +## Closing evidence + +- Sanitized run result: [link to the public result accepted by + `python3 release/scripts/integration-e2-runner.py validate`] +- Frozen Registry Stack candidate: [link to the published release] +- Independent operator: [confirmed or not confirmed; do not identify the + operator] +- Owner-approved non-production source: [confirmed or not confirmed; do not + identify the owner or source] +- Integration profile and reviewed operation: [safe public summary; the exact + values remain in the sanitized result] +- Supported topology and journey: [safe public summary] +- Generated Registry YAML edited by hand: [no, or explain why the pilot does + not close] +- Maintainer comparison of public hashes and flags with restricted evidence: + [confirmed or not confirmed; do not identify the maintainer or expose + restricted evidence] +- Overall outcome: [passed, failed, or incomplete] + +Issue closure still requires a frozen published candidate, an independent +operator, an owner-approved source, and a confirmed maintainer comparison of +the public hashes and flags with the generated project, source audit records, +redaction report, and teardown evidence. A plan or maintainer-run substitute +cannot satisfy any of these requirements. + +## What the pilot did and did not prove + +The pilot showed: [summarize only the bounded journey completed from published +artifacts, including offline checks and the selected Relay and, when +applicable, Notary flow]. + +The pilot did not show: [summarize excluded versions, operations, topologies, +scale, availability, recovery, or other support claims]. It is not upstream +product certification, general country-system conformance, a security audit, +or evidence that Registry Stack is broadly production-ready. + +## Findings and triage + +Use safe summaries and public issue or pull-request links. Record `not +exercised` rather than inferring success. + +| Area | Exercised | Sanitized outcome | Public triage links | +|---|---|---|---| +| Operator handoff and independence | [yes/no] | [summary] | [links or none] | +| Install or deployment | [yes/no] | [summary] | [links or none] | +| Configuration and environment binding | [yes/no] | [summary] | [links or none] | +| Diagnostics and ordinary source failures | [yes/no] | [summary] | [links or none] | +| Upgrade or rollback | [yes/no] | [summary] | [links or none] | +| Restart, teardown, and other operations | [yes/no] | [summary] | [links or none] | +| Security boundaries and redaction | [yes/no] | [summary] | [links or none] | +| Documentation and operator journey | [yes/no] | [summary] | [links or none] | + +### Blocking findings + +List each blocker with its public triage issue, fix, and independent +re-verification link. Unresolved blockers keep the pilot open. + +- [none, or safe finding summary and links] + +### Accepted limitations and narrowed support + +List each owner-approved limitation or narrowed support claim with its public +decision, operator-guidance update, support-wording update, and review trigger. +Do not use this section to waive a blocker silently. + +- [none, or safe limitation summary and links] + +## Conclusion + +[State whether the bounded pilot closes the external-pilot gate, remains +blocked, or requires a narrower support claim. It cannot close unless the +maintainer comparison above is confirmed. Reiterate any unexercised area that +affects the conclusion.] diff --git a/release/conformance/integrations/profiles/dhis2-tracker-2.41.9.profile.json b/release/conformance/integrations/profiles/dhis2-tracker-2.41.9.profile.json index 9c73b6f3d..b74e88f7f 100644 --- a/release/conformance/integrations/profiles/dhis2-tracker-2.41.9.profile.json +++ b/release/conformance/integrations/profiles/dhis2-tracker-2.41.9.profile.json @@ -42,6 +42,9 @@ { "env": "DHIS2_MATERNAL_PROGRAM_UID", "classification": "restricted", "purpose": "maternal programme metadata" }, { "env": "DHIS2_TB_PROGRAM_UID", "classification": "restricted", "purpose": "tuberculosis programme metadata" }, { "env": "DHIS2_CHILD_VISIT_STAGE_UID", "classification": "restricted", "purpose": "child visit stage metadata" }, + { "env": "DHIS2_BCG_BIRTH_STAGE_UID", "classification": "restricted", "purpose": "BCG birth-dose stage metadata" }, + { "env": "DHIS2_OPV_BIRTH_STAGE_UID", "classification": "restricted", "purpose": "OPV birth-dose stage metadata" }, + { "env": "DHIS2_MEASLES_STAGE_UID", "classification": "restricted", "purpose": "measles-dose stage metadata" }, { "env": "DHIS2_FIRST_NAME_ATTRIBUTE_UID", "classification": "restricted", "purpose": "first-name attribute metadata" }, { "env": "DHIS2_LAST_NAME_ATTRIBUTE_UID", "classification": "restricted", "purpose": "last-name attribute metadata" }, { "env": "DHIS2_BIRTH_DATE_ATTRIBUTE_UID", "classification": "restricted", "purpose": "birth-date attribute metadata" }, diff --git a/release/conformance/openid/README.md b/release/conformance/openid/README.md index 19b010f86..99dcb7667 100644 --- a/release/conformance/openid/README.md +++ b/release/conformance/openid/README.md @@ -22,8 +22,9 @@ themselves: - The supported Registry Notary topology must use a frozen release-candidate image pinned by digest and checked-in non-secret configuration. -- The full OID4VCI issuer plan needs an adapter that sends the issuer-initiated - credential offer to the suite's `/credential_offer` callback. +- The owner-only `submit-offer` adapter can send the real issuer-initiated + pre-authorized offer to the suite's `/credential_offer` callback without + exposing it in process arguments or command output. - The upstream full-plan shape currently selects DPoP. Registry Notary 1.0 does not support or claim DPoP, wallet attestation, PAR, EUDI, HAIP, an authorization-code wallet grant, or ES256 holder proof. @@ -45,9 +46,10 @@ and unmodified result status without retaining secrets. Notary's registry-backed OID4VCI issuer. It runs `oid4vci-1_0-issuer-test-plan` with only `oid4vci-1_0-issuer-metadata-test`. -- `notary-oid4vci-issuer-full` is mapped but blocked until a topology adapter - bridges a Notary pre-authorized offer into the suite callback and the suite - path matches the supported Registry Notary profile. +- `notary-oid4vci-issuer-full` is mapped but blocked until the suite path + matches the supported Registry Notary profile. The offer adapter closes only + the callback transport gap; it does not add DPoP, attestation, batch, + notification, or other unsupported product behavior. The suite's `sender_constrain=dpop` selector is required by the upstream plan shape. The metadata-only module does not exercise DPoP, and the selector must @@ -91,6 +93,33 @@ Candidate-only scenarios are directly runnable. `--allow-blocked` is reserved for deliberate investigation of scenarios whose status is explicitly blocked; it does not turn their output into release evidence. +For an issuer-initiated suite module, store the exact +`openid-credential-offer` URI rendered after Notary completes its authenticated +`/oid4vci/offer/callback` in an owner-only file. After `up`, export the exact +self-signed certificate generated for the suite's Nginx service from +`/etc/ssl/certs/nginx-selfsigned.crt`, then submit the offer: + +```bash +release/scripts/openid-conformance-runner.py export-suite-ca \ + --output target/openid-conformance/conformance-suite-ca.pem + +chmod 600 /private/path/notary-offer.txt +release/scripts/openid-conformance-runner.py submit-offer \ + --offer-file /private/path/notary-offer.txt \ + --issuer-url https://issuer.example.test \ + --suite-offer-endpoint 'https://localhost.emobix.co.uk:8443//credential_offer' \ + --suite-ca-certificate target/openid-conformance/conformance-suite-ca.pem +``` + +The adapter accepts only an inline Notary offer with the pre-authorized-code +grant, sends it once to the pinned suite origin without proxies or redirects, +and prints no offer content. TLS uses normal hostname and certificate +validation. The checked-in certificate recipe covers +`localhost.emobix.co.uk`, `localhost`, `127.0.0.1`, and `::1`. The optional CA +file is read once without following symlinks and adds only that explicitly +captured local trust anchor. The export command refuses to overwrite an +existing output. A fabricated offer is not candidate evidence. + Set `REGISTRY_OPENID_CONFORMANCE_AUTHORIZATION_SERVER` when the authorization server differs from the issuer. Set `REGISTRY_OPENID_CONFORMANCE_CREDENTIAL_CONFIGURATION_ID` when the topology @@ -131,7 +160,17 @@ proof JWTs, issued credentials, transaction codes, or seeded civil identifiers. Review and redact an export before turning it into release evidence. A failed or warned result must remain visible in the reviewed summary. +The current runner keeps raw exports private but does not yet generate the +candidate-bound allowlisted summary or review promotion required for release +evidence. Until that source-side gate and a real frozen-candidate exercise +exist, its output remains candidate-exercise material rather than a completed +conformance claim. + The first metadata-only run and its known failures are recorded in [`initial-report.md`](initial-report.md). It is historical context only. It is not evidence for the current candidate, any wallet, any verifier, or the full issuer profile. + +The Rust SD-JWT verifier is a caller-invoked library, not an OID4VP endpoint. +Its library and fixture tests therefore do not support an OID4VP verifier +conformance claim. diff --git a/release/conformance/openid/nginx.Dockerfile b/release/conformance/openid/nginx.Dockerfile index 92491a193..fe87e108a 100644 --- a/release/conformance/openid/nginx.Dockerfile +++ b/release/conformance/openid/nginx.Dockerfile @@ -3,5 +3,9 @@ FROM nginx:1.27.3@sha256:bc2f6a7c8ddbccf55bdb19659ce3b0a92ca6559e86d42677a5a02ef RUN openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \ -keyout /etc/ssl/private/nginx-selfsigned.key \ -out /etc/ssl/certs/nginx-selfsigned.crt \ - -subj "/CN=localhost" + -subj "/CN=localhost.emobix.co.uk" \ + -addext "subjectAltName=DNS:localhost.emobix.co.uk,DNS:localhost,IP:127.0.0.1,IP:::1" \ + -addext "basicConstraints=critical,CA:TRUE" \ + -addext "keyUsage=critical,digitalSignature,keyEncipherment,keyCertSign" \ + -addext "extendedKeyUsage=serverAuth" COPY nginx.conf /etc/nginx/nginx.conf diff --git a/release/conformance/openid/plan-map.json b/release/conformance/openid/plan-map.json index 57b9f84a1..fe00f3de9 100644 --- a/release/conformance/openid/plan-map.json +++ b/release/conformance/openid/plan-map.json @@ -51,7 +51,7 @@ { "id": "notary-oid4vci-issuer-full", "surface": "Registry Notary registry-backed OID4VCI issuer", - "status": "blocked-by-suite-profile-and-offer-adapter", + "status": "blocked-by-suite-profile", "suite_plan": "oid4vci-1_0-issuer-test-plan", "suite_modules": [], "variants": { @@ -66,14 +66,21 @@ "vci_authorization_code_flow_variant": "issuer_initiated" }, "config_template": "registry-notary-oid4vci-issuer.template.json", + "offer_adapter": { + "command": "release/scripts/openid-conformance-runner.py submit-offer", + "input": "The owner-only openid-credential-offer URI rendered only after Registry Notary completes its authenticated /oid4vci/offer/callback and registry-backed evaluation.", + "output": "One no-redirect GET to the pinned suite module's exposed /credential_offer endpoint with the unchanged inline credential_offer value." + }, "requires": [ "A frozen Registry Notary release-candidate topology with a suite-reachable issuer URL.", - "A bridge that sends the issuer-initiated Notary pre-authorized offer to the suite's exposed /credential_offer endpoint.", + "The candidate-neutral submit-offer adapter must receive the real owner-only Notary offer URI produced after the authenticated callback; a fabricated offer does not qualify.", "A reviewed suite path that does not require DPoP, wallet attestation, PAR, an authorization-code wallet grant, ES256 holder proof, EUDI, or HAIP behavior outside Registry Notary 1.0." ], "notes": [ "Registry Notary supports issuer-initiated pre-authorized code only. The identity provider's authorization code is internal to Notary and is not a wallet grant.", + "The pinned suite accepts issuer-initiated pre-authorized offers at its exposed /credential_offer endpoint. The adapter now closes that transport gap without adding a Registry Notary product endpoint.", "The checked-in sender_constrain=dpop variant is required by this upstream suite plan shape and does not represent Registry Notary DPoP support.", + "The full plan remains blocked because its current module and variant roster exercises unsupported DPoP, attestation, batch, notification, and other behavior outside the Registry Notary 1.0 profile.", "Do not treat this blocked mapping, a development topology, or historical wallet smoke output as certification evidence." ] } @@ -84,6 +91,11 @@ "evidence": "The release-owned candidate-neutral Relay and Zitadel smoke is directly runnable against a published Relay image digest. Its live output requires candidate binding and maintainer review before it becomes release evidence.", "reason": "The OIDF suite does not publish a generic resource-server conformance plan for a Relay-style protected API. The separate release topology verifies discovery, signature, audience, token type, and native Zitadel role-object scope mapping." }, + { + "surface": "Registry Notary Rust SD-JWT verifier", + "evidence": "Rust library and fixture interoperability tests only; no OIDF OID4VP verifier-plan result applies to this surface.", + "reason": "The Rust SD-JWT verifier is a caller-invoked verification library, not an OID4VP endpoint. Registry Stack 1.0 therefore makes no OID4VP verifier conformance claim for it." + }, { "surface": "Third-party OpenID Providers used by demonstration topologies", "evidence": "Out of Registry Stack conformance scope.", diff --git a/release/conformance/relay-oidc/README.md b/release/conformance/relay-oidc/README.md index 01ab0696b..7b82188d1 100644 --- a/release/conformance/relay-oidc/README.md +++ b/release/conformance/relay-oidc/README.md @@ -6,20 +6,25 @@ resource server with `auth.mode: oidc`. It does not build Relay from the source checkout and does not depend on a hosted environment or Solmara Lab. The topology uses digest-pinned Zitadel, PostgreSQL, and Python images. The -runner accepts Relay only as the exact image reference -`ghcr.io/registrystack/registry-relay@sha256:`. The operator-supplied -candidate source commit and release identifier are mandatory and recorded for -later comparison with the release manifest. The runner does not derive or -cryptographically verify either identifier from the image. A maintainer must -perform that manifest binding before treating a report as release evidence. +runner resolves Relay from the exact release manifest and matching +`registryctl--image-lock.json` release asset. It rejects a source-ref, +release-tag, product-version, or image-digest mismatch before starting Docker. +The image lock must remain in its downloaded release asset directory alongside +`SHA256SUMS`, the release capsule, the shared release provenance, and the +Cosign signature and certificate files for both the image lock and capsule. +The runner verifies those bindings with installed `cosign` and `slsa-verifier` +before using either product image digest. +For a closed release manifest, it accepts only the byte-exact tag manifest with +the single `stack.status: release-candidate` to `released` closeout transition. +Any other post-tag manifest drift is rejected. ## Evidence boundary The checked-in assets and their offline tests prove that the harness is reviewable and candidate-neutral. They are not live release evidence. A live run writes a report classified as `unreviewed-live-candidate-output` with -`review_required: true`. A maintainer must bind that report to the published -candidate manifest and review it before it can become release evidence. +`review_required: true`. A maintainer must review the embedded candidate +binding and results before the report can become release evidence. The report contains identifiers, configuration and topology digests, bounded diagnostics, and assertion results. It never contains the bootstrap PAT, client @@ -69,21 +74,18 @@ HTTP issuer and discovery URL on loopback, which is the only insecure fetch form Relay permits for local development. The Relay API is published separately on a randomly selected loopback port. -## Offline review +## Candidate review Python 3.11 or later is required. Validate the checked-in topology and render a -candidate-bound plan without Docker or network access: +candidate-bound plan without Docker. Candidate binding invokes `cosign` and +`slsa-verifier`, which may use their normal verification network paths: ```bash -RELAY_IMAGE='ghcr.io/registrystack/registry-relay@sha256:'\ -'<64-lowercase-hex>' - release/scripts/relay-oidc-smoke.py validate release/scripts/relay-oidc-smoke.py plan \ - --relay-image "$RELAY_IMAGE" \ - --candidate-source-ref '<40-lowercase-hex-commit>' \ - --release-id '1.0.0-rc.1' + --release-manifest 'release/manifests/registry-stack-.yaml' \ + --image-lock '/private/path/registryctl-v-image-lock.json' ``` The plan deliberately records `live_evidence: false`. @@ -94,13 +96,9 @@ Docker with Docker Compose is required. The Relay image must already be published by digest: ```bash -RELAY_IMAGE='ghcr.io/registrystack/registry-relay@sha256:'\ -'<64-lowercase-hex>' - release/scripts/relay-oidc-smoke.py run \ - --relay-image "$RELAY_IMAGE" \ - --candidate-source-ref '<40-lowercase-hex-commit>' \ - --release-id '1.0.0-rc.1' + --release-manifest 'release/manifests/registry-stack-.yaml' \ + --image-lock '/private/path/registryctl-v-image-lock.json' ``` The command prints only the path to the unreviewed report. Use `--output-dir` @@ -108,6 +106,12 @@ to choose an empty report directory and `--host-port` only when a fixed free loopback port is required. The default output is under `target/relay-oidc-smoke/`, which Git ignores. +Live execution currently supports only the release-owned topology. Solmara may +be selected for candidate-neutral planning with +`plan --topology solmara --solmara-source-ref '<40-lowercase-hex-commit>'`, +but the plan records no live evidence. A Solmara `run` fails closed until this +runner consumes and hashes the pinned Solmara checkout. + If teardown fails, treat the run as an error. The diagnostic includes the exact random Compose project name only in the local command output, so an operator can inspect and remove that isolated project without risking unrelated Docker diff --git a/release/exercises/README.md b/release/exercises/README.md index 34fb56a2b..8a7d8895a 100644 --- a/release/exercises/README.md +++ b/release/exercises/README.md @@ -28,19 +28,48 @@ release are frozen and independently verified: 1. Copy the template to a candidate-specific JSON file. 2. Change `record_kind` to `candidate_evidence`. 3. Replace every placeholder with an exact version, commit, digest, timestamp, - bounded authority identifier, or evidence label. + bounded authority identifier, or evidence label. `target_release.source_ref` + is the reviewed prepare commit P and `source_commit` is the finalized target + T. The manifest path and hash must identify the manifest stored at T. 4. Hash each committed configuration schema and every complete recovery-set artifact. Do not copy secret values into the record. -5. Exercise every required check against the pinned standalone Solmara +5. Fill the canonical artifact set with the two P and T binary inventories, + image-input inventories, retained image-layout-pair identities, target + images, manifest, image lock, and P/T release-input identities. Its + `sha256` is the SHA-256 of canonical compact JSON for the `artifacts` object + (`sort_keys=True`, separators `,` and `:`). + Under a private candidate-asset root, keep one directory named for each + source and target version. Each version directory contains the downloaded + `registryctl--image-lock.json` beside its `SHA256SUMS`, + signed release capsule, Cosign signatures and certificates, and shared SLSA + provenance. The validator authenticates both releases and requires their + signed tag targets and image pins to match the corresponding record. The + target image-lock byte digest must also match the canonical artifact set. +6. Exercise every required check against the pinned standalone Solmara topology. Record `passed` only when the retained evidence proves the check. -6. Set `candidate_frozen` and `candidate_independently_verified` to `true`. -7. Validate the candidate record without `--template`: + Honest `failed` and `not_run` records remain structurally valid; a `not_run` + result uses null evidence fields. +7. Set both candidate attestations to `true` only after independent review. +8. Validate the record structure, then require every promotion check to pass: ```sh + python3 release/scripts/prepare-upgrade-exercise-assets.py \ + --discover release/exercises \ + --asset-root /private/path/candidate-release-assets python3 release/scripts/validate-upgrade-exercise.py \ + --candidate-asset-root /private/path/candidate-release-assets \ + release/exercises/ + python3 release/scripts/validate-upgrade-exercise.py --require-pass \ + --candidate-asset-root /private/path/candidate-release-assets \ release/exercises/ ``` +The validator authenticates release coordinates and enforces equality among +the redaction-safe P/T inventory and layout digests. The underlying private +inventory, layout, Solmara execution, and result evidence remains subject to +the candidate-freeze and independent-review attestations; it is not ingested +from the public record. + The validator accepts only a bounded schema. It records hashes and labels, not raw commands, logs, database URLs, credentials, tokens, subject identifiers, source rows, audit contents, or key material. Keep the underlying evidence in diff --git a/release/exercises/upgrade-exercise-v1.template.json b/release/exercises/upgrade-exercise-v1.template.json index 2236b93bb..b6c03f42b 100644 --- a/release/exercises/upgrade-exercise-v1.template.json +++ b/release/exercises/upgrade-exercise-v1.template.json @@ -11,11 +11,16 @@ }, "target_release": { "version": "", + "release_id": "", + "source_ref": "", "source_commit": "", "relay_image_digest": "", "notary_image_digest": "" }, - "target_release_manifest_sha256": "", + "target_release_manifest": { + "path": "", + "sha256": "" + }, "candidate_frozen": false, "candidate_independently_verified": false, "config_schemas": { @@ -28,6 +33,29 @@ "sha256": "" } }, + "candidate_artifact_set": { + "sha256": "", + "artifacts": { + "p1_binaries": "", + "p1_image_inputs": "", + "p2_binaries": "", + "p2_image_inputs": "", + "t1_binaries": "", + "t1_image_inputs": "", + "t2_binaries": "", + "t2_image_inputs": "", + "p_notary_layouts": "", + "p_relay_layouts": "", + "t_notary_layouts": "", + "t_relay_layouts": "", + "image_lock": "", + "manifest": "", + "notary_image": "", + "relay_image": "", + "p_release_inputs": "", + "t_release_inputs": "" + } + }, "topology": { "repository": "registrystack/solmara-lab", "release_tag": "", diff --git a/release/scripts/check-debian13-images.py b/release/scripts/check-debian13-images.py index b6aac87c7..d59bce375 100755 --- a/release/scripts/check-debian13-images.py +++ b/release/scripts/check-debian13-images.py @@ -44,6 +44,7 @@ Path("crates/registry-relay/docs/security-assurance.md"), Path("crates/registry-relay/scripts/check_docker_build_contract.py"), Path("crates/registry-relay/scripts/run-live-consultation-journey.sh"), + Path("docs/site/scripts/check-registryctl-tutorials.sh"), Path("products/notary/docs/security-assurance.md"), ) @@ -61,6 +62,10 @@ FROM_RE = re.compile(r"^FROM\s+(?:--platform=\S+\s+)?(\S+)", re.MULTILINE) DIGEST_PIN_RE = re.compile(r"@sha256:[0-9a-f]{64}$") +RETIRED_DEBIAN_RE = re.compile( + r"\b(?:bookworm|debian[\s_:-]*v?[\s_:-]*12)\b", + re.IGNORECASE, +) def read(root: Path, relative: Path, failures: list[str]) -> str: @@ -96,14 +101,11 @@ def check_repository(root: Path = ROOT) -> list[str]: for relative in MAINTAINED_TEXT_PATHS } - retired_markers = ("book" + "worm", "debian" + "12") for relative, text in texts.items(): - lowered = text.casefold() - for marker in retired_markers: - if marker in lowered: - failures.append( - f"{relative}: retired Debian image generation marker remains: {marker}" - ) + if RETIRED_DEBIAN_RE.search(text): + failures.append( + f"{relative}: retired Debian image generation marker remains" + ) for relative in DOCKERFILES: text = texts[relative] @@ -274,6 +276,15 @@ def check_repository(root: Path = ROOT) -> list[str]: failures, ) + tutorial = texts[Path("docs/site/scripts/check-registryctl-tutorials.sh")] + require( + tutorial, + f'BUILDER_IMAGE="{RUST_BUILDER}"', + Path("docs/site/scripts/check-registryctl-tutorials.sh"), + "pinned Debian 13 tutorial builder", + failures, + ) + return failures diff --git a/release/scripts/check-gates-inventory.py b/release/scripts/check-gates-inventory.py index 3d4837d65..377d2c6ac 100644 --- a/release/scripts/check-gates-inventory.py +++ b/release/scripts/check-gates-inventory.py @@ -32,6 +32,10 @@ "Other workflow path classification", ".github/workflows/*)\n mark_all\n ;;", ), + ( + "Debian 13 image contract", + "run: python3 release/scripts/check-debian13-images.py", + ), ("Cargo metadata", "run: cargo metadata --locked --format-version 1"), ( "Manifest profile validation", @@ -182,8 +186,20 @@ "run: python3 -m unittest release/scripts/test_validate_upgrade_exercise.py", ), ( - "Upgrade exercise template validation", - "python3 release/scripts/validate-upgrade-exercise.py --template", + "Upgrade exercise asset preparation tests", + "run: python3 -m unittest release/scripts/test_prepare_upgrade_exercise_assets.py", + ), + ( + "Upgrade exercise candidate asset preparation", + "python3 release/scripts/prepare-upgrade-exercise-assets.py\n" + " --discover release/exercises\n" + " --asset-root target/upgrade-exercise-assets", + ), + ( + "Upgrade exercise record discovery", + "python3 release/scripts/validate-upgrade-exercise.py --discover " + "release/exercises --candidate-asset-root " + "target/upgrade-exercise-assets", ), ( "Base-reference compatibility input", diff --git a/release/scripts/conformance_candidate.py b/release/scripts/conformance_candidate.py new file mode 100644 index 000000000..df9655f27 --- /dev/null +++ b/release/scripts/conformance_candidate.py @@ -0,0 +1,683 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +"""Resolve one immutable Registry Stack candidate for conformance evidence.""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +import shutil +import stat +import subprocess +import tempfile +from contextlib import contextmanager +from pathlib import Path +from typing import Any, Callable, Iterator + +import yaml + + +REPO_ROOT = Path(__file__).resolve().parents[2] +MANIFEST_DIR = REPO_ROOT / "release" / "manifests" +COMMIT = re.compile(r"^[0-9a-f]{40}$") +RELEASE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") +IMAGE_LOCK_FILE = re.compile( + r"^registryctl-(v[A-Za-z0-9][A-Za-z0-9._+-]*)-image-lock\.json$" +) +IMAGE_REPOSITORIES = { + "registry-notary": "ghcr.io/registrystack/registry-notary", + "registry-relay": "ghcr.io/registrystack/registry-relay", +} +CAPSULE_REPOSITORY = "registrystack/registry-stack" +SLSA_SOURCE_URI = "github.com/registrystack/registry-stack" +RELEASE_WORKFLOW = ( + "https://github.com/registrystack/registry-stack/.github/workflows/" + "release.yml@refs/tags/{tag}" +) + + +class CandidateError(RuntimeError): + """Candidate inputs are mutable, malformed, or disagree.""" + + +def normalized_absolute_path(path: Path) -> Path: + return Path(os.path.abspath(path.expanduser())) + + +def require_real_directory(path: Path) -> None: + try: + info = path.lstat() + except OSError as exc: + raise CandidateError( + f"candidate input directory is unavailable: {path}: {exc}" + ) from exc + if stat.S_ISLNK(info.st_mode) or not stat.S_ISDIR(info.st_mode): + raise CandidateError( + "candidate input directory must be a real, non-symlink directory" + ) + + +def open_directory_no_follow(path: Path) -> int: + require_real_directory(path) + no_follow = getattr(os, "O_NOFOLLOW", None) + directory_flag = getattr(os, "O_DIRECTORY", None) + if no_follow is None or directory_flag is None: + raise CandidateError( + "candidate snapshotting requires O_NOFOLLOW and O_DIRECTORY" + ) + try: + return os.open( + path, + os.O_RDONLY | os.O_CLOEXEC | no_follow | directory_flag, + ) + except OSError as exc: + raise CandidateError( + f"candidate input directory could not be opened safely: {exc}" + ) from exc + + +def read_regular_file_at( + directory_fd: int, + name: str, + *, + max_bytes: int, +) -> bytes: + no_follow = getattr(os, "O_NOFOLLOW", None) + if no_follow is None: + raise CandidateError("candidate snapshotting requires O_NOFOLLOW") + source_fd = None + try: + source_fd = os.open( + name, + os.O_RDONLY | os.O_CLOEXEC | os.O_NONBLOCK | no_follow, + dir_fd=directory_fd, + ) + before = os.fstat(source_fd) + if not stat.S_ISREG(before.st_mode): + raise CandidateError( + f"candidate input must be a regular, non-symlink file: {name}" + ) + if before.st_size <= 0 or before.st_size > max_bytes: + raise CandidateError( + f"candidate input size is invalid: {name}" + ) + with os.fdopen(source_fd, "rb") as source: + source_fd = None + content = source.read(max_bytes + 1) + after = os.fstat(source.fileno()) + identity_before = ( + before.st_dev, + before.st_ino, + before.st_size, + before.st_mtime_ns, + before.st_ctime_ns, + ) + identity_after = ( + after.st_dev, + after.st_ino, + after.st_size, + after.st_mtime_ns, + after.st_ctime_ns, + ) + if ( + len(content) != before.st_size + or len(content) > max_bytes + or identity_before != identity_after + ): + raise CandidateError( + f"candidate input changed while snapshotting: {name}" + ) + return content + except OSError as exc: + raise CandidateError( + f"candidate input could not be read safely: {name}: {exc}" + ) from exc + finally: + if source_fd is not None: + os.close(source_fd) + + +def read_regular_file_no_follow(path: Path, *, max_bytes: int) -> bytes: + path = normalized_absolute_path(path) + directory_fd = open_directory_no_follow(path.parent) + try: + return read_regular_file_at( + directory_fd, + path.name, + max_bytes=max_bytes, + ) + finally: + os.close(directory_fd) + + +def candidate_asset_limits(tag: str, lock_name: str) -> dict[str, int]: + capsule_name = f"registry-stack-{tag}-release-capsule.json" + return { + lock_name: 1024 * 1024, + f"{lock_name}.sig": 1024 * 1024, + f"{lock_name}.pem": 1024 * 1024, + capsule_name: 8 * 1024 * 1024, + f"{capsule_name}.sig": 1024 * 1024, + f"{capsule_name}.pem": 1024 * 1024, + f"registry-stack-{tag}-release-provenance.intoto.jsonl": ( + 128 * 1024 * 1024 + ), + "SHA256SUMS": 1024 * 1024, + } + + +@contextmanager +def candidate_asset_snapshot( + image_lock_path: Path, +) -> Iterator[tuple[Path, bytes]]: + """Capture one candidate so parsing and verification cannot see different bytes. + + The source directory is operator-supplied and remains writable. The + security invariant is that passive parsing and external authenticity tools + consume the same private, read-only files even if those source paths are + replaced concurrently. + """ + image_lock_path = normalized_absolute_path(image_lock_path) + match = IMAGE_LOCK_FILE.fullmatch(image_lock_path.name) + if match is None: + raise CandidateError("release image-lock filename is invalid") + tag = match.group(1) + directory_fd = open_directory_no_follow(image_lock_path.parent) + try: + with tempfile.TemporaryDirectory( + prefix="registry-conformance-candidate-" + ) as temporary: + snapshot = Path(temporary) + snapshot_fd = open_directory_no_follow(snapshot) + captured: dict[str, bytes] = {} + try: + for name, max_bytes in candidate_asset_limits( + tag, image_lock_path.name + ).items(): + content = read_regular_file_at( + directory_fd, + name, + max_bytes=max_bytes, + ) + destination_fd = None + try: + destination_fd = os.open( + name, + os.O_WRONLY + | os.O_CREAT + | os.O_EXCL + | os.O_CLOEXEC + | os.O_NOFOLLOW, + 0o600, + dir_fd=snapshot_fd, + ) + with os.fdopen(destination_fd, "wb") as output: + destination_fd = None + output.write(content) + os.fchmod(output.fileno(), 0o400) + finally: + if destination_fd is not None: + os.close(destination_fd) + captured[name] = content + os.fchmod(snapshot_fd, 0o500) + try: + yield snapshot, captured[image_lock_path.name] + finally: + os.fchmod(snapshot_fd, 0o700) + finally: + os.close(snapshot_fd) + except OSError as exc: + raise CandidateError( + f"could not create private candidate snapshot: {exc}" + ) from exc + finally: + os.close(directory_fd) + + +def git_output(arguments: list[str], max_bytes: int) -> bytes: + try: + result = subprocess.run( + ["git", *arguments], + cwd=REPO_ROOT, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + check=False, + timeout=10, + ) + except (OSError, subprocess.SubprocessError): + raise CandidateError("candidate Git binding could not be verified") from None + if result.returncode != 0 or len(result.stdout) > max_bytes: + raise CandidateError("candidate Git binding could not be verified") + return result.stdout + + +def verify_git_binding( + stack: dict[str, Any], + lock: dict[str, Any], + manifest_path: Path, + manifest_bytes: bytes, +) -> None: + tag_target = lock["tag_target"] + tag_ref = f"refs/tags/{stack['source_tag']}^{{commit}}" + resolved = git_output(["rev-parse", "--verify", tag_ref], 41) + if resolved.strip() != tag_target.encode("ascii"): + raise CandidateError("candidate Git binding could not be verified") + git_output( + ["merge-base", "--is-ancestor", stack["source_ref"], tag_target], + 0, + ) + relative_path = manifest_path.relative_to(REPO_ROOT).as_posix() + object_name = f"{tag_target}:{relative_path}" + try: + object_size = int(git_output(["cat-file", "-s", object_name], 16)) + except ValueError: + raise CandidateError("candidate Git binding could not be verified") from None + if not 0 < object_size <= 1024 * 1024: + raise CandidateError("candidate Git binding could not be verified") + tagged_manifest_bytes = git_output(["show", object_name], object_size) + if len(tagged_manifest_bytes) != object_size: + raise CandidateError("candidate Git binding could not be verified") + verify_closeout_manifest_transition( + stack, manifest_bytes, tagged_manifest_bytes + ) + + +def verify_closeout_manifest_transition( + stack: dict[str, Any], + manifest_bytes: bytes, + tagged_manifest_bytes: bytes, +) -> None: + try: + tagged_manifest = yaml.safe_load(tagged_manifest_bytes.decode("utf-8")) + except (UnicodeDecodeError, yaml.YAMLError): + raise CandidateError("candidate Git binding could not be verified") from None + tagged_stack = ( + tagged_manifest.get("stack") + if isinstance(tagged_manifest, dict) + else None + ) + if ( + not isinstance(tagged_stack, dict) + or tagged_stack.get("status") != "release-candidate" + ): + raise CandidateError("candidate Git binding could not be verified") + if manifest_bytes == tagged_manifest_bytes: + if stack.get("status") != "release-candidate": + raise CandidateError("candidate Git binding could not be verified") + return + released_line = b" status: released\n" + candidate_line = b" status: release-candidate\n" + if ( + stack.get("status") != "released" + or manifest_bytes.count(released_line) != 1 + or tagged_manifest_bytes.count(candidate_line) != 1 + or manifest_bytes.replace(released_line, candidate_line, 1) + != tagged_manifest_bytes + ): + raise CandidateError("candidate Git binding could not be verified") + + +def require_regular_file(path: Path, *, max_bytes: int) -> Path: + path = path.expanduser() + try: + info = path.lstat() + except OSError as exc: + raise CandidateError(f"required file is unavailable: {path}: {exc}") from exc + if stat.S_ISLNK(info.st_mode) or not stat.S_ISREG(info.st_mode): + raise CandidateError(f"required path must be a regular file: {path}") + if not 0 < info.st_size <= max_bytes: + raise CandidateError(f"required file has an invalid size: {path}") + return path.resolve() + + +def sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def parse_checksums(path: Path) -> dict[str, str]: + path = require_regular_file(path, max_bytes=1024 * 1024) + checksums: dict[str, str] = {} + for line_number, line in enumerate( + path.read_text(encoding="utf-8").splitlines(), 1 + ): + match = re.fullmatch(r"([0-9a-f]{64}) \*?([^/\x00]+)", line) + if match is None: + raise CandidateError( + f"SHA256SUMS line {line_number} has an unsafe format" + ) + digest, name = match.groups() + if name in checksums: + raise CandidateError(f"SHA256SUMS contains duplicate entry {name}") + checksums[name] = digest + return checksums + + +def find_named(items: Any, name: str, label: str) -> dict[str, Any]: + if not isinstance(items, list) or any( + not isinstance(item, dict) for item in items + ): + raise CandidateError(f"release capsule {label} must be an object array") + matches = [item for item in items if item.get("name") == name] + if len(matches) != 1: + raise CandidateError( + f"release capsule {label} must contain exactly one {name}" + ) + return matches[0] + + +def run_authenticity_command(command: list[str]) -> None: + try: + result = subprocess.run( + command, + text=True, + capture_output=True, + check=False, + timeout=120, + ) + except (OSError, subprocess.SubprocessError): + raise CandidateError( + f"candidate authenticity command could not complete ({command[0]})" + ) from None + if result.returncode != 0: + detail = (result.stderr or result.stdout).strip().splitlines() + suffix = f": {detail[-1]}" if detail else "" + raise CandidateError( + f"candidate authenticity command failed ({command[0]}){suffix}" + ) + + +def verify_release_authenticity( + asset_dir: Path, + tag: str, + subjects: tuple[str, ...], + *, + command_runner: Callable[[list[str]], None] = run_authenticity_command, +) -> None: + cosign = shutil.which("cosign") + slsa = shutil.which("slsa-verifier") + if not cosign or not slsa: + missing = [ + name + for name, path in (("cosign", cosign), ("slsa-verifier", slsa)) + if not path + ] + raise CandidateError( + "candidate authenticity verification requires installed " + + " and ".join(missing) + ) + provenance = require_regular_file( + asset_dir / f"registry-stack-{tag}-release-provenance.intoto.jsonl", + max_bytes=128 * 1024 * 1024, + ) + identity = RELEASE_WORKFLOW.format(tag=tag) + for name in subjects: + subject = require_regular_file( + asset_dir / name, max_bytes=128 * 1024 * 1024 + ) + signature = require_regular_file( + asset_dir / f"{name}.sig", max_bytes=1024 * 1024 + ) + certificate = require_regular_file( + asset_dir / f"{name}.pem", max_bytes=1024 * 1024 + ) + command_runner( + [ + cosign, + "verify-blob", + str(subject), + "--signature", + str(signature), + "--certificate", + str(certificate), + "--certificate-oidc-issuer", + "https://token.actions.githubusercontent.com", + "--certificate-identity", + identity, + ] + ) + command_runner( + [ + slsa, + "verify-artifact", + str(subject), + "--provenance-path", + str(provenance), + "--source-uri", + SLSA_SOURCE_URI, + "--source-tag", + tag, + ] + ) + + +def verify_release_asset_binding( + stack: dict[str, Any], + lock: dict[str, Any], + image_lock_path: Path, + image_lock_sha256: str, +) -> str: + tag = lock["release_tag"] + asset_dir = image_lock_path.parent + lock_name = image_lock_path.name + capsule_name = f"registry-stack-{tag}-release-capsule.json" + capsule_path = require_regular_file( + asset_dir / capsule_name, max_bytes=8 * 1024 * 1024 + ) + checksums = parse_checksums(asset_dir / "SHA256SUMS") + if checksums.get(lock_name) != image_lock_sha256: + raise CandidateError( + "release image lock does not match its SHA256SUMS entry" + ) + try: + capsule = json.loads(capsule_path.read_bytes()) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise CandidateError(f"release capsule is not valid JSON: {exc}") from exc + if ( + not isinstance(capsule, dict) + or capsule.get("release_tag") != tag + or capsule.get("version") != stack["version"] + or capsule.get("repository") != CAPSULE_REPOSITORY + ): + raise CandidateError("release capsule identity does not match the candidate") + source = capsule.get("source") + if ( + not isinstance(source, dict) + or source.get("source_tag") != tag + or source.get("source_ref") != lock["manifest_source_ref"] + or source.get("source_commit") != lock["tag_target"] + ): + raise CandidateError( + "release capsule source lineage does not match the image lock" + ) + lineage = source.get("lineage") + lineage_keys = { + "tag_matches_source_tag", + "head_matches_tag_target", + "source_ref_ancestor_or_equal", + "default_branch_reachable", + } + if ( + not isinstance(lineage, dict) + or set(lineage) != lineage_keys + or any(value is not True for value in lineage.values()) + ): + raise CandidateError("release capsule does not prove source lineage") + lock_entry = find_named(capsule.get("release_files"), lock_name, "release_files") + if ( + lock_entry.get("kind") != "registryctl-release-image-lock" + or lock_entry.get("sha256") != image_lock_sha256 + ): + raise CandidateError( + "release capsule image-lock classification or hash is invalid" + ) + capsule_images = capsule.get("images") + if ( + not isinstance(capsule_images, list) + or len(capsule_images) != len(IMAGE_REPOSITORIES) + or { + item.get("name") + for item in capsule_images + if isinstance(item, dict) + } + != set(IMAGE_REPOSITORIES) + ): + raise CandidateError( + "release capsule must contain exactly the two product images" + ) + for component in IMAGE_REPOSITORIES: + if ( + find_named(capsule_images, component, "images").get("digest_ref") + != lock["images"][component] + ): + raise CandidateError( + "release capsule images do not match the release image lock" + ) + + capsule_sha256 = sha256(capsule_path) + verify_release_authenticity(asset_dir, tag, (lock_name, capsule_name)) + if sha256(image_lock_path) != image_lock_sha256 or sha256( + capsule_path + ) != capsule_sha256: + raise CandidateError("a signed candidate subject changed during verification") + return capsule_sha256 + + +def _load_candidate_snapshot( + manifest_path: Path, + image_lock_path: Path, + manifest_bytes: bytes, + image_lock_bytes: bytes, + *, + topology: str = "release-owned", + solmara_source_ref: str | None = None, +) -> dict[str, Any]: + try: + manifest = yaml.safe_load(manifest_bytes.decode("utf-8")) + lock = json.loads(image_lock_bytes) + except (UnicodeDecodeError, json.JSONDecodeError, yaml.YAMLError) as exc: + raise CandidateError( + f"candidate input is not valid YAML or JSON: {exc}" + ) from exc + stack = manifest.get("stack") if isinstance(manifest, dict) else None + if not isinstance(stack, dict): + raise CandidateError("release manifest is missing stack metadata") + release_id = stack.get("release") + version = stack.get("version") + source_ref = stack.get("source_ref") + if ( + not isinstance(release_id, str) + or RELEASE_ID.fullmatch(release_id) is None + or manifest_path.name != f"registry-stack-{release_id}.yaml" + ): + raise CandidateError("release manifest ID and filename disagree") + if ( + not isinstance(version, str) + or stack.get("source_repo") != "registrystack/registry-stack" + or not isinstance(source_ref, str) + or COMMIT.fullmatch(source_ref) is None + or stack.get("source_tag") != f"v{version}" + or stack.get("status") not in {"release-candidate", "released"} + ): + raise CandidateError( + "release manifest does not identify one immutable candidate" + ) + artifacts = manifest.get("artifacts") + if not isinstance(artifacts, dict) or any( + artifacts.get(component) != version for component in IMAGE_REPOSITORIES + ): + raise CandidateError( + "release manifest product artifacts do not match its version" + ) + + if ( + not isinstance(lock, dict) + or image_lock_path.name != f"registryctl-v{version}-image-lock.json" + or set(lock) + != { + "schema_version", + "release_tag", + "manifest_source_ref", + "tag_target", + "platform", + "images", + } + or lock.get("schema_version") != "registryctl.release_image_lock.v1" + or lock.get("release_tag") != stack["source_tag"] + or lock.get("manifest_source_ref") != source_ref + or not isinstance(lock.get("tag_target"), str) + or COMMIT.fullmatch(lock["tag_target"]) is None + or lock.get("platform") != "linux/amd64" + ): + raise CandidateError("release image lock does not match the manifest") + images = lock.get("images") + if not isinstance(images, dict) or set(images) != set(IMAGE_REPOSITORIES): + raise CandidateError("release image lock must contain only Notary and Relay") + for component, repository in IMAGE_REPOSITORIES.items(): + value = images[component] + if ( + not isinstance(value, str) + or re.fullmatch(rf"{re.escape(repository)}@sha256:[0-9a-f]{{64}}", value) + is None + ): + raise CandidateError(f"{component} is not pinned to its exact digest") + verify_git_binding(stack, lock, manifest_path, manifest_bytes) + image_lock_sha256 = hashlib.sha256(image_lock_bytes).hexdigest() + capsule_sha256 = verify_release_asset_binding( + stack, lock, image_lock_path, image_lock_sha256 + ) + + if topology == "solmara": + if ( + not isinstance(solmara_source_ref, str) + or COMMIT.fullmatch(solmara_source_ref) is None + ): + raise CandidateError("Solmara topology requires one exact source commit") + elif topology != "release-owned" or solmara_source_ref is not None: + raise CandidateError("Solmara must be explicitly selected and commit-pinned") + return { + "release_id": release_id, + "version": version, + "source_repo": stack["source_repo"], + "source_ref": source_ref, + "source_tag": stack["source_tag"], + "tag_target": lock["tag_target"], + "manifest_path": manifest_path.relative_to(REPO_ROOT).as_posix(), + "manifest_sha256": f"sha256:{hashlib.sha256(manifest_bytes).hexdigest()}", + "image_lock_sha256": f"sha256:{image_lock_sha256}", + "release_capsule_sha256": f"sha256:{capsule_sha256}", + "notary_image": images["registry-notary"], + "relay_image": images["registry-relay"], + "topology": topology, + "solmara_source_ref": solmara_source_ref, + } + + +def load_candidate( + manifest_path: Path, + image_lock_path: Path, + *, + topology: str = "release-owned", + solmara_source_ref: str | None = None, +) -> dict[str, Any]: + manifest_path = normalized_absolute_path(manifest_path) + image_lock_path = normalized_absolute_path(image_lock_path) + if manifest_path.parent != MANIFEST_DIR: + raise CandidateError(f"release manifest must be under {MANIFEST_DIR}") + manifest_bytes = read_regular_file_no_follow( + manifest_path, + max_bytes=1024 * 1024, + ) + with candidate_asset_snapshot(image_lock_path) as ( + snapshot, + image_lock_bytes, + ): + return _load_candidate_snapshot( + manifest_path, + snapshot / image_lock_path.name, + manifest_bytes, + image_lock_bytes, + topology=topology, + solmara_source_ref=solmara_source_ref, + ) diff --git a/release/scripts/integration-e2-runner.py b/release/scripts/integration-e2-runner.py index f4b0282ad..ca03df52b 100755 --- a/release/scripts/integration-e2-runner.py +++ b/release/scripts/integration-e2-runner.py @@ -149,6 +149,9 @@ "DHIS2_MATERNAL_PROGRAM_UID", "DHIS2_TB_PROGRAM_UID", "DHIS2_CHILD_VISIT_STAGE_UID", + "DHIS2_BCG_BIRTH_STAGE_UID", + "DHIS2_OPV_BIRTH_STAGE_UID", + "DHIS2_MEASLES_STAGE_UID", "DHIS2_FIRST_NAME_ATTRIBUTE_UID", "DHIS2_LAST_NAME_ATTRIBUTE_UID", "DHIS2_BIRTH_DATE_ATTRIBUTE_UID", diff --git a/release/scripts/openid-conformance-runner.py b/release/scripts/openid-conformance-runner.py index 8f412887b..dee5463cb 100755 --- a/release/scripts/openid-conformance-runner.py +++ b/release/scripts/openid-conformance-runner.py @@ -11,10 +11,13 @@ import os import shutil import ssl +import stat import subprocess import sys +import tempfile import time import urllib.error +import urllib.parse import urllib.request from pathlib import Path from string import Template @@ -35,12 +38,19 @@ SUITE_JAR = "target/fapi-test-suite.jar" SUITE_JAR_STAMP = "target/fapi-test-suite.jar.registry-stack-source-ref" COMPOSE_CONFIG_DIR_ENV = "REGISTRY_OPENID_CONFORMANCE_CONFIG_DIR" +SUITE_CA_CONTAINER_PATH = "/etc/ssl/certs/nginx-selfsigned.crt" +DEFAULT_SUITE_CA_PATH = DEFAULT_WORK_ROOT / "conformance-suite-ca.pem" class RunnerError(RuntimeError): """A user-actionable conformance runner failure.""" +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, request, file_pointer, code, message, headers, url): + return None + + def load_plan_map(path: Path = PLAN_MAP_PATH) -> dict[str, Any]: with path.open(encoding="utf-8") as handle: plan_map = json.load(handle) @@ -340,6 +350,212 @@ def wait_for_suite(base_url: str, timeout_seconds: int) -> None: raise RunnerError(f"conformance suite did not become ready at {url}: {last_error}") +def read_offer(path: Path, issuer_url: str) -> str: + nofollow = getattr(os, "O_NOFOLLOW", None) + cloexec = getattr(os, "O_CLOEXEC", None) + if nofollow is None or cloexec is None or not hasattr(os, "geteuid"): + raise RunnerError("secure offer input handling is unavailable") + descriptor: int | None = None + try: + descriptor = os.open(path, os.O_RDONLY | cloexec | nofollow) + info = os.fstat(descriptor) + if ( + not stat.S_ISREG(info.st_mode) + or info.st_uid != os.geteuid() + or info.st_mode & 0o077 + ): + raise RunnerError("offer input must be an owner-only regular file") + if not 0 < info.st_size <= 65_536: + raise RunnerError("offer input has an invalid size") + with os.fdopen(descriptor, "rb", closefd=True) as handle: + descriptor = None + raw = handle.read(65_537) + except OSError: + raise RunnerError("offer input could not be opened securely") from None + finally: + if descriptor is not None: + os.close(descriptor) + if len(raw) != info.st_size: + raise RunnerError("offer input changed while it was read") + try: + offer_uri = raw.decode("utf-8").strip() + except UnicodeDecodeError: + raise RunnerError("offer input is not valid UTF-8") from None + parsed = urllib.parse.urlsplit(offer_uri) + try: + query = urllib.parse.parse_qs(parsed.query, strict_parsing=True) + except ValueError: + raise RunnerError("offer input has a malformed query") from None + if ( + parsed.scheme != "openid-credential-offer" + or parsed.netloc + or parsed.path + or parsed.fragment + or set(query) != {"credential_offer"} + or len(query["credential_offer"]) != 1 + ): + raise RunnerError("offer input is not one inline credential offer URI") + inline = query["credential_offer"][0] + offer = json.loads(inline) + grant = "urn:ietf:params:oauth:grant-type:pre-authorized_code" + if ( + not isinstance(offer, dict) + or offer.get("credential_issuer") != issuer_url.rstrip("/") + or not isinstance(offer.get("grants"), dict) + or set(offer["grants"]) != {grant} + or not isinstance(offer["grants"][grant], dict) + or not isinstance(offer["grants"][grant].get("pre-authorized_code"), str) + ): + raise RunnerError("offer is not the expected Notary pre-authorized offer") + return inline + + +def read_suite_ca_certificate(path: Path) -> bytes: + path = path.expanduser() + nofollow = getattr(os, "O_NOFOLLOW", 0) + cloexec = getattr(os, "O_CLOEXEC", 0) + descriptor: int | None = None + before: os.stat_result | None = None + try: + if not nofollow: + before = path.lstat() + if stat.S_ISLNK(before.st_mode): + raise RunnerError( + "suite CA certificate could not be opened securely" + ) + descriptor = os.open(path, os.O_RDONLY | nofollow | cloexec) + info = os.fstat(descriptor) + if before is not None and ( + before.st_dev != info.st_dev or before.st_ino != info.st_ino + ): + raise RunnerError("suite CA certificate changed while it was opened") + if ( + not stat.S_ISREG(info.st_mode) + or not 0 < info.st_size <= 1024 * 1024 + ): + raise RunnerError( + "suite CA certificate must be a bounded regular file" + ) + with os.fdopen(descriptor, "rb", closefd=True) as handle: + descriptor = None + certificate = handle.read(1024 * 1024 + 1) + except OSError: + raise RunnerError( + "suite CA certificate could not be opened securely" + ) from None + finally: + if descriptor is not None: + os.close(descriptor) + if len(certificate) != info.st_size: + raise RunnerError("suite CA certificate changed while it was read") + return certificate + + +def add_suite_ca(context: ssl.SSLContext, certificate: bytes) -> None: + try: + text = certificate.decode("ascii") + except UnicodeDecodeError: + cadata: str | bytes = certificate + else: + cadata = text if "-----BEGIN CERTIFICATE-----" in text else certificate + try: + context.load_verify_locations(cadata=cadata) + except (OSError, ValueError): + raise RunnerError("suite CA certificate could not be loaded") from None + + +def suite_tls_context(ca_certificate: Path | None) -> ssl.SSLContext: + if ca_certificate is None: + return ssl.create_default_context() + context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + add_suite_ca(context, read_suite_ca_certificate(ca_certificate)) + return context + + +def cmd_submit_offer(args: argparse.Namespace) -> int: + inline = read_offer(args.offer_file, args.issuer_url) + base = urllib.parse.urlsplit(args.conformance_server) + endpoint = urllib.parse.urlsplit(args.suite_offer_endpoint) + if ( + (endpoint.scheme, endpoint.netloc) != (base.scheme, base.netloc) + or endpoint.scheme != "https" + or not endpoint.path.endswith("/credential_offer") + or endpoint.query + or endpoint.fragment + ): + raise RunnerError( + "suite offer endpoint must use HTTPS on the pinned suite origin" + ) + url = urllib.parse.urlunsplit( + endpoint._replace(query=urllib.parse.urlencode({"credential_offer": inline})) + ) + context = suite_tls_context(args.suite_ca_certificate) + opener = urllib.request.build_opener( + urllib.request.ProxyHandler({}), + urllib.request.HTTPSHandler(context=context), + NoRedirect(), + ) + try: + with opener.open(url, timeout=args.timeout_seconds) as response: + if not 200 <= response.status < 300: + raise RunnerError(f"suite offer endpoint returned HTTP {response.status}") + except urllib.error.HTTPError as exc: + raise RunnerError(f"suite offer endpoint returned HTTP {exc.code}") from None + except (OSError, urllib.error.URLError): + raise RunnerError("suite offer submission failed") from None + print("credential offer submitted") + return 0 + + +def write_new_file(path: Path, content: bytes) -> Path: + path = path.expanduser() + path.parent.mkdir(parents=True, exist_ok=True) + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL + flags |= getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0) + descriptor: int | None = None + try: + descriptor = os.open(path, flags, 0o600) + with os.fdopen(descriptor, "wb", closefd=True) as handle: + descriptor = None + handle.write(content) + except OSError: + raise RunnerError("suite CA output could not be created") from None + finally: + if descriptor is not None: + os.close(descriptor) + return path + + +def cmd_export_suite_ca(args: argparse.Namespace) -> int: + checkout = suite_dir(args) + if not checkout.is_dir(): + raise RunnerError("suite checkout is unavailable; run prepare first") + output = Path(args.output).expanduser() + output.parent.mkdir(parents=True, exist_ok=True) + env = os.environ.copy() + env[COMPOSE_CONFIG_DIR_ENV] = str(CONFIG_DIR) + with tempfile.TemporaryDirectory( + prefix=".openid-suite-ca-", dir=output.parent + ) as tmp: + copied = Path(tmp) / "nginx-selfsigned.crt" + run_checked( + compose_command( + checkout, + args, + "cp", + f"nginx:{SUITE_CA_CONTAINER_PATH}", + str(copied), + ), + env=env, + ) + certificate = read_suite_ca_certificate(copied) + validation_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + add_suite_ca(validation_context, certificate) + write_new_file(output, certificate) + print(output) + return 0 + + def output_dir_for(args: argparse.Namespace, scenario_id: str) -> Path: if args.output_dir: return Path(args.output_dir).expanduser().resolve() @@ -498,6 +714,17 @@ def parse_args(argv: list[str]) -> argparse.Namespace: add_common(down_parser) down_parser.set_defaults(func=cmd_down) + export_ca_parser = subparsers.add_parser("export-suite-ca") + export_ca_parser.add_argument("--cache-dir", default=str(DEFAULT_CACHE_DIR)) + export_ca_parser.add_argument("--suite-dir") + export_ca_parser.add_argument( + "--output", + type=Path, + default=DEFAULT_SUITE_CA_PATH, + help="new file that receives the running suite's generated certificate", + ) + export_ca_parser.set_defaults(func=cmd_export_suite_ca) + render_parser = subparsers.add_parser("render-config") add_common(render_parser) add_config_args(render_parser) @@ -512,6 +739,22 @@ def parse_args(argv: list[str]) -> argparse.Namespace: run_parser.add_argument("--wait-seconds", type=int, default=180) run_parser.set_defaults(func=cmd_run) + offer_parser = subparsers.add_parser("submit-offer") + offer_parser.add_argument("--offer-file", type=Path, required=True) + offer_parser.add_argument("--issuer-url", required=True) + offer_parser.add_argument("--suite-offer-endpoint", required=True) + offer_parser.add_argument( + "--conformance-server", + default=load_plan_map()["suite"]["base_url"], + ) + offer_parser.add_argument( + "--suite-ca-certificate", + type=Path, + help="PEM or DER trust anchor captured from the local suite", + ) + offer_parser.add_argument("--timeout-seconds", type=int, default=10) + offer_parser.set_defaults(func=cmd_submit_offer) + return parser.parse_args(argv) diff --git a/release/scripts/prepare-upgrade-exercise-assets.py b/release/scripts/prepare-upgrade-exercise-assets.py new file mode 100644 index 000000000..3a48989f3 --- /dev/null +++ b/release/scripts/prepare-upgrade-exercise-assets.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +"""Download version-keyed release assets for committed upgrade evidence.""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +from pathlib import Path +from typing import Callable + + +STACK_REPOSITORY = "registrystack/registry-stack" +SEMVER_NUMBER = r"(?:0|[1-9][0-9]*)" +SEMVER_PRERELEASE_IDENTIFIER = ( + rf"(?:{SEMVER_NUMBER}|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)" +) +VERSION = re.compile( + rf"^v{SEMVER_NUMBER}\.{SEMVER_NUMBER}\.{SEMVER_NUMBER}" + rf"(?:-{SEMVER_PRERELEASE_IDENTIFIER}" + rf"(?:\.{SEMVER_PRERELEASE_IDENTIFIER})*)?$" +) + + +class PreparationError(RuntimeError): + """Committed upgrade evidence cannot be prepared safely.""" + + +def candidate_versions(records: Path) -> tuple[str, ...]: + versions: set[str] = set() + for path in sorted(records.glob("*.json")): + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + raise PreparationError( + "upgrade exercise record could not be read" + ) from None + if not isinstance(value, dict) or value.get("record_kind") == "template": + continue + if value.get("record_kind") != "candidate_evidence": + raise PreparationError("upgrade exercise record kind is invalid") + for label in ("source_release", "target_release"): + release = value.get(label) + version = release.get("version") if isinstance(release, dict) else None + if not isinstance(version, str) or VERSION.fullmatch(version) is None: + raise PreparationError( + f"candidate upgrade {label.removesuffix('_release')} version is invalid" + ) + versions.add(version) + return tuple(sorted(versions)) + + +def required_asset_names(version: str) -> tuple[str, ...]: + image_lock = f"registryctl-{version}-image-lock.json" + capsule = f"registry-stack-{version}-release-capsule.json" + return ( + image_lock, + f"{image_lock}.sig", + f"{image_lock}.pem", + capsule, + f"{capsule}.sig", + f"{capsule}.pem", + f"registry-stack-{version}-release-provenance.intoto.jsonl", + "SHA256SUMS", + ) + + +def run_download(command: list[str]) -> None: + try: + result = subprocess.run( + command, + text=True, + capture_output=True, + check=False, + timeout=120, + ) + except (OSError, subprocess.SubprocessError): + raise PreparationError( + "candidate release assets could not be downloaded" + ) from None + if result.returncode != 0: + raise PreparationError( + "candidate release assets could not be downloaded" + ) + + +def prepare_assets( + records: Path, + asset_root: Path, + *, + downloader: Callable[[list[str]], None] = run_download, +) -> tuple[str, ...]: + versions = candidate_versions(records) + if not versions: + return versions + asset_root.mkdir(parents=True, exist_ok=True) + for version in versions: + destination = asset_root / version + try: + destination.mkdir(mode=0o700) + except OSError: + raise PreparationError( + "candidate version asset directory must be new" + ) from None + names = required_asset_names(version) + command = [ + "gh", + "release", + "download", + version, + "--repo", + STACK_REPOSITORY, + "--dir", + str(destination), + ] + for name in names: + command.extend(("--pattern", name)) + downloader(command) + try: + actual = {path.name for path in destination.iterdir()} + except OSError: + raise PreparationError( + "candidate release asset set could not be inspected" + ) from None + if actual != set(names) or any( + not path.is_file() or path.is_symlink() + for path in destination.iterdir() + ): + raise PreparationError( + "candidate release asset set is incomplete or unsafe" + ) + return versions + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--discover", type=Path, required=True) + parser.add_argument("--asset-root", type=Path, required=True) + parser.add_argument("--github-output", type=Path) + args = parser.parse_args() + try: + versions = prepare_assets(args.discover, args.asset_root) + if args.github_output is not None: + with args.github_output.open("a", encoding="utf-8") as output: + output.write( + f"has_candidates={'true' if versions else 'false'}\n" + ) + output.write(f"versions={','.join(versions)}\n") + except (PreparationError, OSError) as error: + print(f"upgrade asset preparation failed: {error}", file=sys.stderr) + return 1 + print( + f"prepared upgrade release inputs for {len(versions)} version(s)" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/release/scripts/relay-oidc-smoke.py b/release/scripts/relay-oidc-smoke.py index 27c1f9b69..175651a15 100755 --- a/release/scripts/relay-oidc-smoke.py +++ b/release/scripts/relay-oidc-smoke.py @@ -25,6 +25,8 @@ from string import Template from typing import Any +from conformance_candidate import CandidateError, load_candidate + REPO_ROOT = Path(__file__).resolve().parents[2] CONFIG_DIR = REPO_ROOT / "release" / "conformance" / "relay-oidc" @@ -34,12 +36,7 @@ HELPER_PATH = CONFIG_DIR / "zitadel-helper.py" DEFAULT_WORK_ROOT = REPO_ROOT / "target" / "relay-oidc-smoke" SCHEMA_VERSION = "registry.release.relay_oidc_smoke.v1" -RELAY_IMAGE_RE = re.compile( - r"^ghcr\.io/registrystack/registry-relay@sha256:[0-9a-f]{64}$" -) DIGEST_IMAGE_RE = re.compile(r"^[^\s@]+:[^\s@]+@sha256:[0-9a-f]{64}$") -SOURCE_REF_RE = re.compile(r"^[0-9a-f]{40}$") -RELEASE_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+-]{0,63}$") RUN_ID_RE = re.compile(r"^[0-9a-f]{12}$") MAX_HTTP_BODY = 65_536 REQUIRED_CHECKS = ( @@ -91,29 +88,6 @@ def file_sha256(path: Path) -> str: return sha256_bytes(path.read_bytes()) -def validate_relay_image(value: str) -> str: - if not RELAY_IMAGE_RE.fullmatch(value): - raise SmokeError( - "Relay image must be exactly " - "ghcr.io/registrystack/registry-relay@sha256:<64 lowercase hex>" - ) - return value - - -def validate_source_ref(value: str) -> str: - if not SOURCE_REF_RE.fullmatch(value): - raise SmokeError( - "candidate source ref must be 40 lowercase hexadecimal characters" - ) - return value - - -def validate_release_id(value: str) -> str: - if not RELEASE_ID_RE.fullmatch(value): - raise SmokeError("release id contains unsupported characters or is too long") - return value - - def compose_image_entries(text: str) -> list[str]: return [ match.group(1).strip() @@ -188,23 +162,24 @@ def validate_assets() -> dict[str, Any]: } -def plan_document(relay_image: str, source_ref: str, release_id: str) -> dict[str, Any]: +def plan_document(candidate: dict[str, Any]) -> dict[str, Any]: assets = validate_assets() return { "schema_version": SCHEMA_VERSION, "operation": "relay-oidc-smoke", "classification": "candidate-neutral-harness-plan", - "release_id": validate_release_id(release_id), - "candidate_source_ref": validate_source_ref(source_ref), - "relay_image": validate_relay_image(relay_image), + "candidate": candidate, "topology": assets, "checks": list(REQUIRED_CHECKS), - "plan_network_required": False, + "plan_network_required": True, "live_run_requires_docker": True, "live_run_network_required": True, "live_evidence": False, "notes": [ - "This plan validates checked-in inputs only and is not conformance evidence.", + ( + "This plan validates the checked-in harness and authenticated " + "candidate inputs, but is not conformance evidence." + ), ( "A live run remains unreviewed until its digest-bound report is " + "reviewed without raw secrets." @@ -602,9 +577,7 @@ def safe_report( "classification", "review_required", "contains_sensitive_material", - "release_id", - "candidate_source_ref", - "relay_image", + "candidate", "started_at", "completed_at", "result", @@ -614,6 +587,7 @@ def safe_report( "topology", "configuration_digests", "checks", + "canary_scan", } unexpected = set(report) - allowed_keys if unexpected: @@ -647,11 +621,25 @@ def default_output_dir() -> Path: return DEFAULT_WORK_ROOT / f"run-{stamp}-{secrets.token_hex(3)}" +def candidate_from_args(args: argparse.Namespace) -> dict[str, Any]: + return load_candidate( + args.release_manifest, + args.image_lock, + topology=args.topology, + solmara_source_ref=args.solmara_source_ref, + ) + + def execute_live(args: argparse.Namespace) -> Path: + if args.topology == "solmara": + raise SmokeError( + "Solmara live candidate evidence is unsupported until this runner " + "consumes and hashes the pinned Solmara checkout; use plan only for " + "non-evidence preparation" + ) assets = validate_assets() - relay_image = validate_relay_image(args.relay_image) - source_ref = validate_source_ref(args.candidate_source_ref) - release_id = validate_release_id(args.release_id) + candidate = candidate_from_args(args) + relay_image = candidate["relay_image"] if not shutil.which("docker"): raise SmokeError("Docker with Compose is required for a live smoke") @@ -703,9 +691,7 @@ def execute_live(args: argparse.Namespace) -> Path: "classification": "unreviewed-live-candidate-output", "review_required": True, "contains_sensitive_material": False, - "release_id": release_id, - "candidate_source_ref": source_ref, - "relay_image": relay_image, + "candidate": candidate, "started_at": utc_now(), "completed_at": None, "result": "error", @@ -715,6 +701,10 @@ def execute_live(args: argparse.Namespace) -> Path: "topology": assets, "configuration_digests": {}, "checks": [], + "canary_scan": { + "passed": True, + "seeded_canaries": 1, + }, } stage = "topology-start" primary_error: SmokeError | None = None @@ -947,7 +937,7 @@ def cmd_validate(args: argparse.Namespace) -> int: def cmd_plan(args: argparse.Namespace) -> int: print( json.dumps( - plan_document(args.relay_image, args.candidate_source_ref, args.release_id), + plan_document(candidate_from_args(args)), indent=2, sort_keys=True, ) @@ -961,9 +951,18 @@ def cmd_run(args: argparse.Namespace) -> int: def add_candidate_args(parser: argparse.ArgumentParser) -> None: - parser.add_argument("--relay-image", required=True) - parser.add_argument("--candidate-source-ref", required=True) - parser.add_argument("--release-id", required=True) + parser.add_argument("--release-manifest", type=Path, required=True) + parser.add_argument("--image-lock", type=Path, required=True) + parser.add_argument( + "--topology", + choices=("release-owned", "solmara"), + default="release-owned", + help="candidate topology; live execution currently supports release-owned only", + ) + parser.add_argument( + "--solmara-source-ref", + help="pinned Solmara commit for planning; Solmara live execution is unsupported", + ) def parse_args(argv: list[str]) -> argparse.Namespace: @@ -986,6 +985,7 @@ def parse_args(argv: list[str]) -> argparse.Namespace: run_parser.add_argument("--host-port", type=int) run_parser.add_argument("--output-dir") run_parser.set_defaults(func=cmd_run) + return parser.parse_args(argv) @@ -993,7 +993,7 @@ def main(argv: list[str] | None = None) -> int: args = parse_args(argv or sys.argv[1:]) try: return int(args.func(args)) - except (OSError, SmokeError) as exc: + except (CandidateError, OSError, SmokeError) as exc: print(f"relay-oidc-smoke: {exc}", file=sys.stderr) return 2 diff --git a/release/scripts/test_check_gates_inventory.py b/release/scripts/test_check_gates_inventory.py index a050580f3..de68eedb2 100644 --- a/release/scripts/test_check_gates_inventory.py +++ b/release/scripts/test_check_gates_inventory.py @@ -121,6 +121,13 @@ def test_missing_relay_exposure_gate_is_reported(self) -> None: text = self.workflow.replace("name: Relay exposure check", "name: Relay exposure") self.assertIn("Relay exposure check", self.module.missing_gates(text)) + def test_missing_debian13_image_contract_is_reported(self) -> None: + text = self.workflow.replace( + "run: python3 release/scripts/check-debian13-images.py", + "run: true", + ) + self.assertIn("Debian 13 image contract", self.module.missing_gates(text)) + def test_missing_pull_request_concurrency_group_is_reported(self) -> None: text = self.workflow.replace( "format('pr-{0}', github.event.pull_request.number)", @@ -367,13 +374,32 @@ def test_missing_openapi_base_reference_is_reported(self) -> None: ) self.assertIn("OpenAPI base-reference input", self.module.missing_gates(text)) - def test_missing_upgrade_exercise_template_validation_is_reported(self) -> None: + def test_missing_upgrade_exercise_record_discovery_is_reported(self) -> None: + text = self.workflow.replace( + "python3 release/scripts/validate-upgrade-exercise.py --discover release/exercises", + "python3 release/scripts/validate-upgrade-exercise.py --skip-discovery", + ) + self.assertIn( + "Upgrade exercise record discovery", self.module.missing_gates(text) + ) + + def test_missing_upgrade_exercise_asset_preparation_is_reported(self) -> None: + text = self.workflow.replace( + "python3 release/scripts/prepare-upgrade-exercise-assets.py", + "python3 release/scripts/skip-upgrade-exercise-assets.py", + ) + self.assertIn( + "Upgrade exercise candidate asset preparation", + self.module.missing_gates(text), + ) + + def test_missing_upgrade_exercise_asset_root_is_reported(self) -> None: text = self.workflow.replace( - "python3 release/scripts/validate-upgrade-exercise.py --template", - "python3 release/scripts/validate-upgrade-exercise.py --skip-template", + "--candidate-asset-root target/upgrade-exercise-assets", + "--candidate-asset-root target/unauthenticated-assets", ) self.assertIn( - "Upgrade exercise template validation", self.module.missing_gates(text) + "Upgrade exercise record discovery", self.module.missing_gates(text) ) def test_missing_stable_error_registry_path_filter_is_reported(self) -> None: diff --git a/release/scripts/test_integration_e2_runner.py b/release/scripts/test_integration_e2_runner.py index 77061386a..5ee0fd7e2 100644 --- a/release/scripts/test_integration_e2_runner.py +++ b/release/scripts/test_integration_e2_runner.py @@ -268,6 +268,59 @@ def write_result(self, result: dict[str, object]) -> Path: def test_checked_in_packet_is_closed_and_valid(self) -> None: self.module.validate_packet() + def test_pilot_report_template_preserves_the_public_contract(self) -> None: + readme = (self.module.CONFIG_DIR / "README.md").read_text(encoding="utf-8") + template = ( + self.module.CONFIG_DIR / "pilot-report.template.md" + ).read_text(encoding="utf-8") + normalized_template = " ".join(template.split()) + self.assertIn("(pilot-report.template.md)", readme) + self.assertIn( + "Do not include credentials, network origins, operator or source " + "identifiers, record identifiers, raw audits, private evidence, or " + "links to restricted evidence.", + normalized_template, + ) + self.assertNotIn("Solmara", template) + self.assertIn( + "python3 release/scripts/integration-e2-runner.py validate", + normalized_template, + ) + self.assertNotIn("schema-valid public result", normalized_template) + self.assertIn( + "Issue closure still requires a frozen published candidate, an " + "independent operator, an owner-approved source, and a confirmed " + "maintainer comparison of the public hashes and flags with the " + "generated project, source audit records, redaction report, and " + "teardown evidence.", + normalized_template, + ) + self.assertIn( + "It cannot close unless the maintainer comparison above is confirmed.", + normalized_template, + ) + for text in ( + "Sanitized run result:", + "Plans, dry runs", + "One completed pilot is not proof of broad production readiness.", + "Frozen Registry Stack candidate:", + "Independent operator:", + "Owner-approved non-production source:", + "Maintainer comparison of public hashes and flags with restricted " + "evidence:", + "### Blocking findings", + "### Accepted limitations and narrowed support", + "Operator handoff and independence", + "Install or deployment", + "Configuration and environment binding", + "Diagnostics and ordinary source failures", + "Upgrade or rollback", + "Restart, teardown, and other operations", + "Security boundaries and redaction", + "Documentation and operator journey", + ): + self.assertIn(text, template) + def test_nested_result_objects_must_remain_closed(self) -> None: schema = self.module.load_json(self.module.SCHEMA_PATH) schema["$defs"]["case"]["additionalProperties"] = True @@ -302,6 +355,28 @@ def test_dry_run_is_explicitly_not_evidence_and_hides_values(self) -> None: any("compatibility probe" in item for item in plan["prerequisites"]) ) + def test_dhis2_plan_includes_reviewed_child_health_metadata(self) -> None: + profile = self.module.load_profile("dhis2-tracker-2.41.9") + plan = self.module.plan_document(profile) + required = set(plan["required_input_names"]) + self.assertTrue( + { + "DHIS2_CHILD_PROGRAM_UID", + "DHIS2_CHILD_VISIT_STAGE_UID", + "DHIS2_BCG_BIRTH_STAGE_UID", + "DHIS2_OPV_BIRTH_STAGE_UID", + "DHIS2_MEASLES_STAGE_UID", + }.issubset(required) + ) + self.assertTrue( + { + "DHIS2_MATERNAL_PROGRAM_UID", + "DHIS2_TB_PROGRAM_UID", + "DHIS2_FIRST_NAME_ATTRIBUTE_UID", + "DHIS2_BIRTH_DATE_ATTRIBUTE_UID", + }.issubset(required) + ) + def test_candidate_assets_cross_validate_all_release_bindings(self) -> None: candidate = self.make_candidate() metadata = self.candidate_metadata(candidate) diff --git a/release/scripts/test_openid_conformance_runner.py b/release/scripts/test_openid_conformance_runner.py index 245b0ccfa..a34c2b491 100755 --- a/release/scripts/test_openid_conformance_runner.py +++ b/release/scripts/test_openid_conformance_runner.py @@ -5,16 +5,24 @@ import importlib.util import json import re +import shlex import shutil +import socket +import ssl +import subprocess import sys import tempfile +import threading +import urllib.parse +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from unittest import TestCase, main -from unittest.mock import patch +from unittest.mock import MagicMock, patch SCRIPT_DIR = Path(__file__).resolve().parent RUNNER_PATH = SCRIPT_DIR / "openid-conformance-runner.py" +NGINX_DOCKERFILE = SCRIPT_DIR.parent / "conformance" / "openid" / "nginx.Dockerfile" def load_runner(): @@ -29,12 +37,45 @@ def load_runner(): return module +def nginx_certificate_command(certificate: Path, private_key: Path) -> list[str]: + dockerfile = NGINX_DOCKERFILE.read_text(encoding="utf-8") + recipe = dockerfile.split("RUN ", 1)[1].split("\nCOPY ", 1)[0] + command = shlex.split(recipe.replace("\\\n", " ")) + command[command.index("-out") + 1] = str(certificate) + command[command.index("-keyout") + 1] = str(private_key) + return command + + +class EmptyHttpsHandler(BaseHTTPRequestHandler): + def do_GET(self) -> None: + self.send_response(204) + self.end_headers() + + def log_message(self, _format: str, *_args) -> None: + return + + class OpenIdConformanceRunnerTest(TestCase): @classmethod def setUpClass(cls) -> None: cls.runner = load_runner() cls.plan_map = cls.runner.load_plan_map() + def offer_uri( + self, issuer: str = "https://issuer.example.test" + ) -> tuple[str, str]: + grant = "urn:ietf:params:oauth:grant-type:pre-authorized_code" + inline = json.dumps( + { + "credential_issuer": issuer, + "credential_configuration_ids": ["person_is_alive_sd_jwt"], + "grants": {grant: {"pre-authorized_code": "owner-only-code"}}, + } + ) + return inline, "openid-credential-offer://?" + urllib.parse.urlencode( + {"credential_offer": inline} + ) + def test_plan_map_has_unique_scenarios_and_pinned_suite_ref(self) -> None: scenarios = self.plan_map["scenarios"] self.assertEqual(len(scenarios), len({scenario["id"] for scenario in scenarios})) @@ -79,9 +120,7 @@ def test_notary_mapping_is_candidate_only_and_matches_the_1_0_profile(self) -> N self.assertIn("does not support or claim DPoP", metadata_notes) self.assertIn("frozen candidate artifact", metadata_notes) - self.assertEqual( - "blocked-by-suite-profile-and-offer-adapter", full["status"] - ) + self.assertEqual("blocked-by-suite-profile", full["status"]) self.assertEqual( "pre_authorization_code", full["variants"]["vci_grant_type"] ) @@ -92,6 +131,412 @@ def test_notary_mapping_is_candidate_only_and_matches_the_1_0_profile(self) -> N "policy decision on whether the first full run targets", full_contract, ) + verifier = next( + item + for item in self.plan_map["non_oidf_surfaces"] + if item["surface"] == "Registry Notary Rust SD-JWT verifier" + ) + self.assertIn("not an OID4VP endpoint", verifier["reason"]) + + def test_submit_offer_forwards_only_the_real_notary_preauthorized_offer( + self, + ) -> None: + issuer = "https://issuer.example.test" + inline, offer_uri = self.offer_uri(issuer) + with tempfile.TemporaryDirectory() as tmp: + offer_file = Path(tmp) / "offer.txt" + offer_file.write_text(offer_uri, encoding="utf-8") + offer_file.chmod(0o600) + args = self.runner.parse_args( + [ + "submit-offer", + "--offer-file", + str(offer_file), + "--issuer-url", + issuer, + "--suite-offer-endpoint", + "https://suite.example.test/run/credential_offer", + "--conformance-server", + "https://suite.example.test", + ] + ) + response = MagicMock() + response.__enter__.return_value.status = 204 + opener = MagicMock() + opener.open.return_value = response + tls_context = MagicMock() + with patch.object( + self.runner.ssl, + "create_default_context", + return_value=tls_context, + ) as create_context: + with patch.object( + self.runner.ssl, + "_create_unverified_context", + side_effect=AssertionError("unverified TLS must not be used"), + ): + with patch.object( + self.runner.urllib.request, + "build_opener", + return_value=opener, + ) as build_opener: + with patch("builtins.print") as printed: + self.assertEqual(0, self.runner.cmd_submit_offer(args)) + + submitted = urllib.parse.urlsplit(opener.open.call_args.args[0]) + self.assertEqual( + [inline], + urllib.parse.parse_qs(submitted.query)["credential_offer"], + ) + create_context.assert_called_once_with() + https_handler = next( + handler + for handler in build_opener.call_args.args + if isinstance(handler, self.runner.urllib.request.HTTPSHandler) + ) + self.assertIs(tls_context, https_handler._context) + printed.assert_called_once_with("credential offer submitted") + + opener.open.side_effect = self.runner.urllib.error.URLError(inline) + with patch.object( + self.runner.urllib.request, "build_opener", return_value=opener + ): + with self.assertRaisesRegex( + self.runner.RunnerError, "submission failed" + ) as caught: + self.runner.cmd_submit_offer(args) + self.assertNotIn("owner-only-code", str(caught.exception)) + + def test_submit_offer_rejects_untrusted_remote_tls(self) -> None: + issuer = "https://issuer.example.test" + inline, offer_uri = self.offer_uri(issuer) + with tempfile.TemporaryDirectory() as tmp: + offer_file = Path(tmp) / "offer.txt" + offer_file.write_text(offer_uri, encoding="utf-8") + offer_file.chmod(0o600) + args = self.runner.parse_args( + [ + "submit-offer", + "--offer-file", + str(offer_file), + "--issuer-url", + issuer, + "--suite-offer-endpoint", + "https://suite.example.test/run/credential_offer", + "--conformance-server", + "https://suite.example.test", + ] + ) + opener = MagicMock() + opener.open.side_effect = self.runner.urllib.error.URLError( + self.runner.ssl.SSLCertVerificationError( + 1, "self-signed certificate" + ) + ) + with patch.object( + self.runner.urllib.request, "build_opener", return_value=opener + ): + with self.assertRaisesRegex( + self.runner.RunnerError, "submission failed" + ) as caught: + self.runner.cmd_submit_offer(args) + + self.assertNotIn(inline, str(caught.exception)) + + def test_submit_offer_accepts_an_explicit_local_suite_ca(self) -> None: + issuer = "https://issuer.example.test" + _, offer_uri = self.offer_uri(issuer) + with tempfile.TemporaryDirectory() as tmp: + offer_file = Path(tmp) / "offer.txt" + offer_file.write_text(offer_uri, encoding="utf-8") + offer_file.chmod(0o600) + suite_ca = Path(tmp) / "suite-ca.pem" + suite_ca.write_text("local test CA", encoding="utf-8") + args = self.runner.parse_args( + [ + "submit-offer", + "--offer-file", + str(offer_file), + "--issuer-url", + issuer, + "--suite-offer-endpoint", + "https://localhost.emobix.co.uk:8443/run/credential_offer", + "--conformance-server", + "https://localhost.emobix.co.uk:8443", + "--suite-ca-certificate", + str(suite_ca), + ] + ) + response = MagicMock() + response.__enter__.return_value.status = 204 + opener = MagicMock() + opener.open.return_value = response + tls_context = MagicMock() + with patch.object( + self.runner.ssl, + "SSLContext", + return_value=tls_context, + ) as create_context: + with patch.object( + self.runner.urllib.request, + "build_opener", + return_value=opener, + ): + with patch("builtins.print"): + self.assertEqual(0, self.runner.cmd_submit_offer(args)) + + create_context.assert_called_once_with( + self.runner.ssl.PROTOCOL_TLS_CLIENT + ) + tls_context.load_verify_locations.assert_called_once_with( + cadata=b"local test CA" + ) + + def test_suite_ca_read_holds_one_descriptor_across_path_replacement( + self, + ) -> None: + original = ( + b"-----BEGIN CERTIFICATE-----\n" + b"captured-original\n" + b"-----END CERTIFICATE-----\n" + ) + replacement = ( + b"-----BEGIN CERTIFICATE-----\n" + b"replacement\n" + b"-----END CERTIFICATE-----\n" + ) + with tempfile.TemporaryDirectory() as tmp: + ca_path = Path(tmp) / "suite-ca.pem" + replacement_path = Path(tmp) / "replacement.pem" + ca_path.write_bytes(original) + replacement_path.write_bytes(replacement) + real_open = self.runner.os.open + + def open_then_replace(path, flags): + descriptor = real_open(path, flags) + self.runner.os.replace(replacement_path, ca_path) + return descriptor + + tls_context = MagicMock() + with patch.object( + self.runner.os, "open", side_effect=open_then_replace + ) as secure_open: + with patch.object( + self.runner.ssl, + "SSLContext", + return_value=tls_context, + ): + self.runner.suite_tls_context(ca_path) + + self.assertEqual(replacement, ca_path.read_bytes()) + secure_open.assert_called_once() + flags = secure_open.call_args.args[1] + for required_flag in ("O_NOFOLLOW", "O_CLOEXEC"): + value = getattr(self.runner.os, required_flag, 0) + if value: + self.assertEqual(value, flags & value) + tls_context.load_verify_locations.assert_called_once_with( + cadata=original.decode("ascii") + ) + + def test_suite_ca_loader_preserves_der_bytes(self) -> None: + tls_context = MagicMock() + certificate = b"\x30\x82\x01\x00\xff" + + self.runner.add_suite_ca(tls_context, certificate) + + tls_context.load_verify_locations.assert_called_once_with( + cadata=certificate + ) + + def test_suite_ca_read_rejects_symlink(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + target = Path(tmp) / "suite-ca.pem" + target.write_text("certificate", encoding="utf-8") + link = Path(tmp) / "suite-ca-link.pem" + link.symlink_to(target) + + with self.assertRaisesRegex( + self.runner.RunnerError, "opened securely" + ): + self.runner.read_suite_ca_certificate(link) + + def test_exported_certificate_recipe_authenticates_documented_suite_host( + self, + ) -> None: + openssl = shutil.which("openssl") + if not openssl: + self.skipTest("openssl is required for the checked-in certificate recipe") + issuer = "https://issuer.example.test" + _, offer_uri = self.offer_uri(issuer) + with tempfile.TemporaryDirectory() as tmp: + work = Path(tmp) + certificate = work / "recipe.crt" + private_key = work / "recipe.key" + command = nginx_certificate_command(certificate, private_key) + self.assertEqual(openssl, shutil.which(command[0])) + self.assertIn( + ( + "subjectAltName=DNS:localhost.emobix.co.uk,DNS:localhost," + "IP:127.0.0.1,IP:::1" + ), + command, + ) + subprocess.run( + command, + check=True, + capture_output=True, + text=True, + ) + + suite_dir = work / "suite" + suite_dir.mkdir() + exported = work / "conformance-suite-ca.pem" + export_args = self.runner.parse_args( + [ + "export-suite-ca", + "--suite-dir", + str(suite_dir), + "--output", + str(exported), + ] + ) + compose_commands: list[list[str]] = [] + + def copy_container_certificate( + compose_command: list[str], **_kwargs + ) -> None: + compose_commands.append(compose_command) + Path(compose_command[-1]).write_bytes(certificate.read_bytes()) + + with patch.object( + self.runner, + "run_checked", + side_effect=copy_container_certificate, + ): + with patch("builtins.print"): + self.assertEqual(0, self.runner.cmd_export_suite_ca(export_args)) + + self.assertEqual(certificate.read_bytes(), exported.read_bytes()) + self.assertEqual(0o600, exported.stat().st_mode & 0o777) + self.assertEqual( + f"nginx:{self.runner.SUITE_CA_CONTAINER_PATH}", + compose_commands[0][-2], + ) + + offer_file = work / "offer.txt" + offer_file.write_text(offer_uri, encoding="utf-8") + offer_file.chmod(0o600) + server = ThreadingHTTPServer(("127.0.0.1", 0), EmptyHttpsHandler) + server_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + server_context.minimum_version = ssl.TLSVersion.TLSv1_2 + server_context.load_cert_chain(certificate, private_key) + server.socket = server_context.wrap_socket( + server.socket, server_side=True + ) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + host = "localhost.emobix.co.uk" + port = server.server_port + submit_args = self.runner.parse_args( + [ + "submit-offer", + "--offer-file", + str(offer_file), + "--issuer-url", + issuer, + "--suite-offer-endpoint", + f"https://{host}:{port}/run/credential_offer", + "--conformance-server", + f"https://{host}:{port}", + "--suite-ca-certificate", + str(exported), + ] + ) + real_getaddrinfo = socket.getaddrinfo + + def loopback_suite(hostname, *args, **kwargs): + if hostname == host: + hostname = "127.0.0.1" + return real_getaddrinfo(hostname, *args, **kwargs) + + try: + with patch.object( + socket, "getaddrinfo", side_effect=loopback_suite + ): + with patch("builtins.print"): + self.assertEqual( + 0, self.runner.cmd_submit_offer(submit_args) + ) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=2) + + def test_submit_offer_rejects_cleartext_suite_endpoint(self) -> None: + issuer = "https://issuer.example.test" + _, offer_uri = self.offer_uri(issuer) + with tempfile.TemporaryDirectory() as tmp: + offer_file = Path(tmp) / "offer.txt" + offer_file.write_text(offer_uri, encoding="utf-8") + offer_file.chmod(0o600) + args = self.runner.parse_args( + [ + "submit-offer", + "--offer-file", + str(offer_file), + "--issuer-url", + issuer, + "--suite-offer-endpoint", + "http://suite.example.test/run/credential_offer", + "--conformance-server", + "http://suite.example.test", + ] + ) + with patch.object( + self.runner.urllib.request, "build_opener" + ) as build_opener: + with self.assertRaisesRegex(self.runner.RunnerError, "HTTPS"): + self.runner.cmd_submit_offer(args) + + build_opener.assert_not_called() + + def test_read_offer_uses_one_no_follow_descriptor(self) -> None: + issuer = "https://issuer.example.test" + inline, offer_uri = self.offer_uri(issuer) + with tempfile.TemporaryDirectory() as tmp: + offer_file = Path(tmp) / "offer.txt" + offer_file.write_text(offer_uri, encoding="utf-8") + offer_file.chmod(0o600) + real_open = self.runner.os.open + with patch.object(Path, "read_text", side_effect=AssertionError): + with patch.object( + self.runner.os, "open", wraps=real_open + ) as secure_open: + self.assertEqual( + inline, self.runner.read_offer(offer_file, issuer) + ) + + secure_open.assert_called_once_with( + offer_file, + self.runner.os.O_RDONLY + | self.runner.os.O_CLOEXEC + | self.runner.os.O_NOFOLLOW, + ) + + def test_read_offer_rejects_symlink(self) -> None: + issuer = "https://issuer.example.test" + _, offer_uri = self.offer_uri(issuer) + with tempfile.TemporaryDirectory() as tmp: + target = Path(tmp) / "offer.txt" + target.write_text(offer_uri, encoding="utf-8") + target.chmod(0o600) + link = Path(tmp) / "offer-link.txt" + link.symlink_to(target) + with self.assertRaisesRegex( + self.runner.RunnerError, "could not be opened securely" + ): + self.runner.read_offer(link, issuer) def test_builder_override_pins_maven_image_by_digest(self) -> None: override = self.runner.BUILDER_COMPOSE_OVERRIDE_PATH.read_text( @@ -448,7 +893,7 @@ def test_blocked_full_scenario_requires_explicit_override(self) -> None: ] ) with self.assertRaisesRegex( - self.runner.RunnerError, "blocked-by-suite-profile-and-offer-adapter" + self.runner.RunnerError, "blocked-by-suite-profile" ): self.runner.cmd_run(args) diff --git a/release/scripts/test_prepare_upgrade_exercise_assets.py b/release/scripts/test_prepare_upgrade_exercise_assets.py new file mode 100644 index 000000000..78eb48d75 --- /dev/null +++ b/release/scripts/test_prepare_upgrade_exercise_assets.py @@ -0,0 +1,182 @@ +from __future__ import annotations + +import importlib.util +import json +import sys +import tempfile +import unittest +import unittest.mock +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +SCRIPT = ROOT / "release" / "scripts" / "prepare-upgrade-exercise-assets.py" + + +def load_module(): + spec = importlib.util.spec_from_file_location( + "prepare_upgrade_exercise_assets", SCRIPT + ) + if spec is None or spec.loader is None: + raise ImportError(f"could not load module spec from {SCRIPT}") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +class PrepareUpgradeExerciseAssetsTest(unittest.TestCase): + def setUp(self) -> None: + self.module = load_module() + + def write_record( + self, + directory: Path, + name: str, + version: str, + *, + source_version: str = "v0.11.0", + ) -> None: + (directory / name).write_text( + json.dumps( + { + "record_kind": "candidate_evidence", + "source_release": {"version": source_version}, + "target_release": {"version": version}, + } + ), + encoding="utf-8", + ) + + def download_fixture( + self, command: list[str], *, omit: str | None = None + ) -> None: + destination = Path(command[command.index("--dir") + 1]) + patterns = [ + command[index + 1] + for index, value in enumerate(command) + if value == "--pattern" + ] + for name in patterns: + if name != omit: + (destination / name).write_text("release asset", encoding="utf-8") + + def test_current_templates_require_no_download_or_asset_root(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + asset_root = Path(temporary) / "assets" + downloader = unittest.mock.Mock(side_effect=AssertionError) + versions = self.module.prepare_assets( + ROOT / "release" / "exercises", + asset_root, + downloader=downloader, + ) + + self.assertEqual((), versions) + self.assertFalse(asset_root.exists()) + downloader.assert_not_called() + + def test_one_candidate_downloads_exact_version_asset_set(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + records = root / "records" + records.mkdir() + self.write_record(records, "candidate.json", "v0.12.2") + commands: list[list[str]] = [] + + def download(command: list[str]) -> None: + commands.append(command) + self.download_fixture(command) + + versions = self.module.prepare_assets( + records, root / "assets", downloader=download + ) + + self.assertEqual(("v0.11.0", "v0.12.2"), versions) + for version in versions: + self.assertEqual( + set(self.module.required_asset_names(version)), + { + path.name + for path in (root / "assets" / version).iterdir() + }, + ) + self.assertEqual( + ["v0.11.0", "v0.12.2"], + [command[3] for command in commands], + ) + + def test_multiple_versions_use_separate_authenticated_directories(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + records = root / "records" + records.mkdir() + self.write_record(records, "candidate-a.json", "v0.12.1") + self.write_record(records, "candidate-b.json", "v0.12.2") + self.write_record(records, "candidate-c.json", "v0.12.2") + + versions = self.module.prepare_assets( + records, + root / "assets", + downloader=self.download_fixture, + ) + + self.assertEqual(("v0.11.0", "v0.12.1", "v0.12.2"), versions) + self.assertTrue((root / "assets" / "v0.11.0").is_dir()) + self.assertTrue((root / "assets" / "v0.12.1").is_dir()) + self.assertTrue((root / "assets" / "v0.12.2").is_dir()) + + def test_invalid_source_version_is_rejected_before_download(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + records = root / "records" + records.mkdir() + self.write_record( + records, + "candidate.json", + "v0.12.2", + source_version="v0.11.0-rc..1", + ) + downloader = unittest.mock.Mock() + with self.assertRaisesRegex( + self.module.PreparationError, "source version is invalid" + ): + self.module.prepare_assets( + records, + root / "assets", + downloader=downloader, + ) + downloader.assert_not_called() + + def test_missing_release_asset_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + records = root / "records" + records.mkdir() + self.write_record(records, "candidate.json", "v0.12.2") + with self.assertRaisesRegex( + self.module.PreparationError, "incomplete or unsafe" + ): + self.module.prepare_assets( + records, + root / "assets", + downloader=lambda command: self.download_fixture( + command, omit="SHA256SUMS" + ), + ) + + def test_missing_github_cli_is_reported_without_command_output(self) -> None: + with unittest.mock.patch.object( + self.module.subprocess, + "run", + side_effect=FileNotFoundError("gh missing"), + ): + with self.assertRaisesRegex( + self.module.PreparationError, "could not be downloaded" + ) as caught: + self.module.run_download(["gh", "release", "download", "v0.12.2"]) + + self.assertNotIn("gh missing", str(caught.exception)) + + +if __name__ == "__main__": + unittest.main() diff --git a/release/scripts/test_registry_release.py b/release/scripts/test_registry_release.py index 19e0ffab7..764851266 100755 --- a/release/scripts/test_registry_release.py +++ b/release/scripts/test_registry_release.py @@ -62,6 +62,34 @@ def test_debian13_contract_rejects_retired_base_and_unpinned_base(self) -> None: self.assertTrue( any("not pinned by immutable digest" in failure for failure in failures) ) + + def test_debian13_contract_rejects_other_tutorial_builders(self) -> None: + module = load_debian13_image_check() + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + for relative in module.MAINTAINED_TEXT_PATHS: + destination = root / relative + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_text( + (ROOT / relative).read_text(encoding="utf-8"), + encoding="utf-8", + ) + + tutorial = root / "docs/site/scripts/check-registryctl-tutorials.sh" + text = tutorial.read_text(encoding="utf-8") + tutorial.write_text( + text.replace(module.RUST_BUILDER, "rust:1.95-debian-12", 1), + encoding="utf-8", + ) + + failures = module.check_repository(root) + self.assertTrue( + any("retired Debian image generation marker" in failure for failure in failures) + ) + self.assertTrue( + any("pinned Debian 13 tutorial builder" in failure for failure in failures) + ) + def test_contributing_documents_major_functionality_test_policy(self) -> None: text = (ROOT / "CONTRIBUTING.md").read_text(encoding="utf-8") diff --git a/release/scripts/test_relay_oidc_smoke.py b/release/scripts/test_relay_oidc_smoke.py index 34dd34274..a16138e0a 100644 --- a/release/scripts/test_relay_oidc_smoke.py +++ b/release/scripts/test_relay_oidc_smoke.py @@ -4,11 +4,16 @@ import base64 import contextlib +import hashlib import importlib.util import json +import os +import stat +import subprocess import sys import tempfile import threading +from argparse import Namespace from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from unittest import TestCase, main @@ -18,6 +23,7 @@ SCRIPT_DIR = Path(__file__).resolve().parent RUNNER_PATH = SCRIPT_DIR / "relay-oidc-smoke.py" HELPER_PATH = SCRIPT_DIR.parent / "conformance" / "relay-oidc" / "zitadel-helper.py" +sys.path.insert(0, str(SCRIPT_DIR)) def load_runner(): @@ -147,6 +153,80 @@ def topology(self) -> dict[str, str]: "service_account_org_id": "org-1", } + def write_candidate_evidence( + self, image_lock: Path, lock: dict[str, object] + ) -> None: + lock_bytes = json.dumps(lock).encode("utf-8") + image_lock.write_bytes(lock_bytes) + lock_sha256 = hashlib.sha256(lock_bytes).hexdigest() + tag = str(lock["release_tag"]) + capsule_name = f"registry-stack-{tag}-release-capsule.json" + images = lock["images"] + if not isinstance(images, dict): + raise AssertionError("test image lock images must be an object") + capsule = { + "release_tag": tag, + "version": tag.removeprefix("v"), + "repository": "registrystack/registry-stack", + "source": { + "source_tag": tag, + "source_ref": lock["manifest_source_ref"], + "source_commit": lock["tag_target"], + "lineage": { + "tag_matches_source_tag": True, + "head_matches_tag_target": True, + "source_ref_ancestor_or_equal": True, + "default_branch_reachable": True, + }, + }, + "release_files": [ + { + "name": image_lock.name, + "kind": "registryctl-release-image-lock", + "sha256": lock_sha256, + } + ], + "images": [ + {"name": name, "digest_ref": digest_ref} + for name, digest_ref in images.items() + ], + } + capsule_path = image_lock.parent / capsule_name + capsule_path.write_text(json.dumps(capsule), encoding="utf-8") + (image_lock.parent / "SHA256SUMS").write_text( + f"{lock_sha256} {image_lock.name}\n", encoding="utf-8" + ) + provenance = ( + image_lock.parent + / f"registry-stack-{tag}-release-provenance.intoto.jsonl" + ) + provenance.write_text("{}\n", encoding="utf-8") + for subject in (image_lock, capsule_path): + subject.with_name(f"{subject.name}.sig").write_text( + "signature", encoding="utf-8" + ) + subject.with_name(f"{subject.name}.pem").write_text( + "certificate", encoding="utf-8" + ) + + def beta_15_lock(self, relay_digest: str = "d") -> dict[str, object]: + return { + "schema_version": "registryctl.release_image_lock.v1", + "release_tag": "v0.12.1", + "manifest_source_ref": "a6f409259158f44c9fdbe99242ec0f9ac10d9373", + "tag_target": "567fb93704d25855238e11fd43c7cb9bf8a2f28e", + "platform": "linux/amd64", + "images": { + "registry-notary": ( + "ghcr.io/registrystack/registry-notary@sha256:" + "c" * 64 + ), + "registry-relay": ( + "ghcr.io/registrystack/registry-relay@sha256:" + + relay_digest * 64 + ), + }, + } + def test_assets_are_candidate_neutral_and_digest_pinned(self) -> None: assets = self.runner.validate_assets() compose = self.runner.COMPOSE_PATH.read_text(encoding="utf-8") @@ -160,44 +240,501 @@ def test_assets_are_candidate_neutral_and_digest_pinned(self) -> None: self.assertRegex(image, self.runner.DIGEST_IMAGE_RE) self.assertNotIn("ghcr.io/registrystack/registry-relay@sha256:", compose) - def test_relay_image_requires_exact_repository_and_lowercase_digest(self) -> None: - valid = "ghcr.io/registrystack/registry-relay@sha256:" + "a" * 64 - self.assertEqual(valid, self.runner.validate_relay_image(valid)) - - invalid = ( - "ghcr.io/registrystack/registry-relay:1.0.0", - "ghcr.io/registrystack/registry-relay:1.0.0@sha256:" + "a" * 64, - "example.test/registry-relay@sha256:" + "a" * 64, - "ghcr.io/registrystack/registry-relay@sha256:" + "A" * 64, - ) - for image in invalid: - with self.subTest(image=image): - with self.assertRaises(self.runner.SmokeError): - self.runner.validate_relay_image(image) - - def test_plan_is_offline_and_does_not_claim_live_evidence(self) -> None: - plan = self.runner.plan_document( - "ghcr.io/registrystack/registry-relay@sha256:" + "a" * 64, - "b" * 40, - "1.0.0-rc.1", - ) + def test_plan_declares_authenticity_network_and_no_live_evidence(self) -> None: + candidate = { + "release_id": "beta-17", + "source_ref": "a" * 40, + "topology": "solmara", + "solmara_source_ref": "b" * 40, + } + plan = self.runner.plan_document(candidate) - self.assertFalse(plan["plan_network_required"]) + self.assertTrue(plan["plan_network_required"]) self.assertTrue(plan["live_run_requires_docker"]) self.assertTrue(plan["live_run_network_required"]) self.assertFalse(plan["live_evidence"]) self.assertEqual(list(self.runner.REQUIRED_CHECKS), plan["checks"]) self.assertEqual("candidate-neutral-harness-plan", plan["classification"]) + self.assertEqual(candidate, plan["candidate"]) - def test_candidate_identifiers_are_bounded(self) -> None: - self.assertEqual("a" * 40, self.runner.validate_source_ref("a" * 40)) - self.assertEqual("1.0.0-rc.1", self.runner.validate_release_id("1.0.0-rc.1")) - for source_ref in ("a" * 39, "A" * 40, "main"): - with self.assertRaises(self.runner.SmokeError): - self.runner.validate_source_ref(source_ref) - for release_id in ("", "contains space", "a" * 65): - with self.assertRaises(self.runner.SmokeError): - self.runner.validate_release_id(release_id) + def test_solmara_live_run_cannot_emit_evidence_from_metadata(self) -> None: + args = Namespace( + topology="solmara", + solmara_source_ref="b" * 40, + output_dir=None, + ) + with ( + patch.object(self.runner, "validate_assets") as validate_assets, + patch.object(self.runner, "candidate_from_args") as load_candidate, + patch.object(self.runner, "safe_report") as write_report, + ): + with self.assertRaisesRegex( + self.runner.SmokeError, + "unsupported until this runner consumes and hashes", + ): + self.runner.execute_live(args) + + validate_assets.assert_not_called() + load_candidate.assert_not_called() + write_report.assert_not_called() + + def test_release_owned_live_run_reaches_existing_candidate_path(self) -> None: + args = Namespace(topology="release-owned", output_dir=None) + candidate = { + "relay_image": ("ghcr.io/registrystack/registry-relay@sha256:" + "d" * 64) + } + with ( + patch.object( + self.runner, "validate_assets", return_value={} + ) as validate_assets, + patch.object( + self.runner, "candidate_from_args", return_value=candidate + ) as load_candidate, + patch.object(self.runner.shutil, "which", return_value=None), + ): + with self.assertRaisesRegex( + self.runner.SmokeError, "Docker with Compose is required" + ): + self.runner.execute_live(args) + + validate_assets.assert_called_once_with() + load_candidate.assert_called_once_with(args) + + def test_candidate_binding_rejects_manifest_lock_and_image_mismatches(self) -> None: + candidate_module = sys.modules["conformance_candidate"] + with tempfile.TemporaryDirectory() as tmp: + manifest = ( + self.runner.REPO_ROOT / "release/manifests/registry-stack-beta-15.yaml" + ) + image_lock = Path(tmp) / "registryctl-v0.12.1-image-lock.json" + lock = { + "schema_version": "registryctl.release_image_lock.v1", + "release_tag": "v0.12.1", + "manifest_source_ref": "a6f409259158f44c9fdbe99242ec0f9ac10d9373", + "tag_target": "567fb93704d25855238e11fd43c7cb9bf8a2f28e", + "platform": "linux/amd64", + "images": { + "registry-notary": ( + "ghcr.io/registrystack/registry-notary@sha256:" + "c" * 64 + ), + "registry-relay": ( + "ghcr.io/registrystack/registry-relay@sha256:" + "d" * 64 + ), + }, + } + self.write_candidate_evidence(image_lock, lock) + args = Namespace( + release_manifest=manifest, + image_lock=image_lock, + topology="release-owned", + solmara_source_ref=None, + ) + with patch.object( + candidate_module, "verify_release_authenticity" + ) as authenticate: + candidate = self.runner.candidate_from_args(args) + self.assertEqual("beta-15", candidate["release_id"]) + self.assertEqual(lock["images"]["registry-relay"], candidate["relay_image"]) + authenticate.assert_called_once() + + lock["manifest_source_ref"] = "e" * 40 + image_lock.write_text(json.dumps(lock), encoding="utf-8") + with self.assertRaisesRegex( + candidate_module.CandidateError, "does not match" + ): + self.runner.candidate_from_args(args) + + lock["manifest_source_ref"] = "a6f409259158f44c9fdbe99242ec0f9ac10d9373" + lock["images"]["registry-relay"] = "registry-relay:mutable" + image_lock.write_text(json.dumps(lock), encoding="utf-8") + with self.assertRaisesRegex(candidate_module.CandidateError, "not pinned"): + self.runner.candidate_from_args(args) + + lock["images"]["registry-relay"] = ( + "ghcr.io/registrystack/registry-relay@sha256:" + "d" * 64 + ) + lock["tag_target"] = "b" * 40 + image_lock.write_text(json.dumps(lock), encoding="utf-8") + with self.assertRaisesRegex(candidate_module.CandidateError, "Git binding"): + self.runner.candidate_from_args(args) + + def test_candidate_binding_rejects_locally_replaced_release_assets(self) -> None: + candidate_module = sys.modules["conformance_candidate"] + with tempfile.TemporaryDirectory() as tmp: + manifest = ( + self.runner.REPO_ROOT / "release/manifests/registry-stack-beta-15.yaml" + ) + image_lock = Path(tmp) / "registryctl-v0.12.1-image-lock.json" + lock = { + "schema_version": "registryctl.release_image_lock.v1", + "release_tag": "v0.12.1", + "manifest_source_ref": "a6f409259158f44c9fdbe99242ec0f9ac10d9373", + "tag_target": "567fb93704d25855238e11fd43c7cb9bf8a2f28e", + "platform": "linux/amd64", + "images": { + "registry-notary": ( + "ghcr.io/registrystack/registry-notary@sha256:" + "c" * 64 + ), + "registry-relay": ( + "ghcr.io/registrystack/registry-relay@sha256:" + "d" * 64 + ), + }, + } + self.write_candidate_evidence(image_lock, lock) + args = Namespace( + release_manifest=manifest, + image_lock=image_lock, + topology="release-owned", + solmara_source_ref=None, + ) + + lock["images"]["registry-relay"] = ( + "ghcr.io/registrystack/registry-relay@sha256:" + "e" * 64 + ) + image_lock.write_text(json.dumps(lock), encoding="utf-8") + with patch.object( + candidate_module, "verify_release_authenticity" + ) as authenticate: + with self.assertRaisesRegex( + candidate_module.CandidateError, "SHA256SUMS" + ): + self.runner.candidate_from_args(args) + authenticate.assert_not_called() + + tampered_sha256 = hashlib.sha256(image_lock.read_bytes()).hexdigest() + (image_lock.parent / "SHA256SUMS").write_text( + f"{tampered_sha256} {image_lock.name}\n", encoding="utf-8" + ) + with patch.object( + candidate_module, "verify_release_authenticity" + ) as authenticate: + with self.assertRaisesRegex( + candidate_module.CandidateError, "classification or hash" + ): + self.runner.candidate_from_args(args) + authenticate.assert_not_called() + + self.write_candidate_evidence(image_lock, lock) + with patch.object( + candidate_module, + "verify_release_authenticity", + side_effect=candidate_module.CandidateError( + "candidate authenticity rejected" + ), + ): + with self.assertRaisesRegex( + candidate_module.CandidateError, "authenticity rejected" + ): + self.runner.candidate_from_args(args) + + def test_candidate_authentication_cannot_swap_parsed_source_assets( + self, + ) -> None: + candidate_module = sys.modules["conformance_candidate"] + with tempfile.TemporaryDirectory() as tmp: + asset_dir = Path(tmp) + image_lock = asset_dir / "registryctl-v0.12.1-image-lock.json" + signed_lock = self.beta_15_lock() + self.write_candidate_evidence(image_lock, signed_lock) + capsule = asset_dir / "registry-stack-v0.12.1-release-capsule.json" + signed_lock_bytes = image_lock.read_bytes() + signed_capsule_bytes = capsule.read_bytes() + + forged_lock = self.beta_15_lock(relay_digest="e") + self.write_candidate_evidence(image_lock, forged_lock) + forged_lock_bytes = image_lock.read_bytes() + forged_capsule_bytes = capsule.read_bytes() + snapshot_paths: list[Path] = [] + + def authenticate( + snapshot: Path, + _tag: str, + _subjects: tuple[str, ...], + ) -> None: + snapshot_paths.append(snapshot) + image_lock.write_bytes(signed_lock_bytes) + capsule.write_bytes(signed_capsule_bytes) + try: + if ( + snapshot.joinpath(image_lock.name).read_bytes() + != signed_lock_bytes + ): + raise candidate_module.CandidateError( + "authenticated bytes do not match parsed bytes" + ) + finally: + image_lock.write_bytes(forged_lock_bytes) + capsule.write_bytes(forged_capsule_bytes) + + manifest = ( + self.runner.REPO_ROOT + / "release/manifests/registry-stack-beta-15.yaml" + ) + with patch.object( + candidate_module, + "verify_release_authenticity", + side_effect=authenticate, + ): + with self.assertRaisesRegex( + candidate_module.CandidateError, + "authenticated bytes do not match parsed bytes", + ): + candidate_module.load_candidate(manifest, image_lock) + + self.assertEqual(forged_lock_bytes, image_lock.read_bytes()) + self.assertEqual(forged_capsule_bytes, capsule.read_bytes()) + self.assertEqual(1, len(snapshot_paths)) + self.assertFalse(snapshot_paths[0].exists()) + + def test_candidate_verifiers_receive_private_read_only_snapshot(self) -> None: + candidate_module = sys.modules["conformance_candidate"] + with tempfile.TemporaryDirectory() as tmp: + asset_dir = Path(tmp) + image_lock = asset_dir / "registryctl-v0.12.1-image-lock.json" + self.write_candidate_evidence(image_lock, self.beta_15_lock()) + snapshot_paths: list[Path] = [] + + def authenticate( + snapshot: Path, + _tag: str, + _subjects: tuple[str, ...], + ) -> None: + snapshot_paths.append(snapshot) + self.assertNotEqual(asset_dir, snapshot) + self.assertEqual( + 0o500, stat.S_IMODE(snapshot.stat().st_mode) + ) + self.assertEqual(os.geteuid(), snapshot.stat().st_uid) + for path in snapshot.iterdir(): + self.assertEqual(0o400, stat.S_IMODE(path.stat().st_mode)) + self.assertFalse(path.is_symlink()) + + manifest = ( + self.runner.REPO_ROOT + / "release/manifests/registry-stack-beta-15.yaml" + ) + with patch.object( + candidate_module, + "verify_release_authenticity", + side_effect=authenticate, + ): + candidate = candidate_module.load_candidate(manifest, image_lock) + + self.assertEqual("beta-15", candidate["release_id"]) + self.assertEqual(1, len(snapshot_paths)) + self.assertFalse(snapshot_paths[0].exists()) + + def test_candidate_snapshot_rejects_symlink_asset(self) -> None: + candidate_module = sys.modules["conformance_candidate"] + with tempfile.TemporaryDirectory() as tmp: + asset_dir = Path(tmp) + image_lock = asset_dir / "registryctl-v0.12.1-image-lock.json" + self.write_candidate_evidence(image_lock, self.beta_15_lock()) + signature = image_lock.with_name(f"{image_lock.name}.sig") + signature_target = asset_dir / "replacement.sig" + signature_target.write_text("signature", encoding="utf-8") + signature.unlink() + signature.symlink_to(signature_target) + manifest = ( + self.runner.REPO_ROOT + / "release/manifests/registry-stack-beta-15.yaml" + ) + + with patch.object( + candidate_module, "verify_release_authenticity" + ) as authenticate: + with self.assertRaisesRegex( + candidate_module.CandidateError, + "read safely", + ): + candidate_module.load_candidate(manifest, image_lock) + authenticate.assert_not_called() + + def test_candidate_authenticity_command_timeout_is_bounded(self) -> None: + candidate_module = sys.modules["conformance_candidate"] + with patch.object( + candidate_module.subprocess, + "run", + side_effect=subprocess.TimeoutExpired("cosign", 120), + ): + with self.assertRaisesRegex( + candidate_module.CandidateError, + "could not complete", + ): + candidate_module.run_authenticity_command( + ["/tools/cosign", "verify-blob", "subject"] + ) + + def test_candidate_authenticity_is_pinned_to_the_tagged_release_workflow( + self, + ) -> None: + candidate_module = sys.modules["conformance_candidate"] + with tempfile.TemporaryDirectory() as tmp: + asset_dir = Path(tmp) + tag = "v0.12.1" + subjects = ( + f"registryctl-{tag}-image-lock.json", + f"registry-stack-{tag}-release-capsule.json", + ) + for name in subjects: + (asset_dir / name).write_text("subject", encoding="utf-8") + (asset_dir / f"{name}.sig").write_text( + "signature", encoding="utf-8" + ) + (asset_dir / f"{name}.pem").write_text( + "certificate", encoding="utf-8" + ) + ( + asset_dir + / f"registry-stack-{tag}-release-provenance.intoto.jsonl" + ).write_text("{}\n", encoding="utf-8") + commands: list[list[str]] = [] + tool_paths = { + "cosign": "/tools/cosign", + "slsa-verifier": "/tools/slsa-verifier", + } + with patch.object( + candidate_module.shutil, + "which", + side_effect=lambda name: tool_paths.get(name), + ): + candidate_module.verify_release_authenticity( + asset_dir, tag, subjects, command_runner=commands.append + ) + + self.assertEqual(4, len(commands)) + identity = candidate_module.RELEASE_WORKFLOW.format(tag=tag) + for command in commands[::2]: + self.assertEqual("/tools/cosign", command[0]) + self.assertIn(identity, command) + for command in commands[1::2]: + self.assertEqual("/tools/slsa-verifier", command[0]) + self.assertEqual(tag, command[command.index("--source-tag") + 1]) + self.assertEqual( + candidate_module.SLSA_SOURCE_URI, + command[command.index("--source-uri") + 1], + ) + + def test_candidate_binding_accepts_only_the_manifest_closeout_transition( + self, + ) -> None: + candidate_module = sys.modules["conformance_candidate"] + with tempfile.TemporaryDirectory() as tmp: + image_lock = Path(tmp) / "registryctl-v0.12.2-image-lock.json" + lock = { + "schema_version": "registryctl.release_image_lock.v1", + "release_tag": "v0.12.2", + "manifest_source_ref": ( + "0e76f5ea61f78bbc15d91fcb6e9dfcaa956c3df8" + ), + "tag_target": "e25f081ce800ade13e892503cc19b96588e081ef", + "platform": "linux/amd64", + "images": { + "registry-notary": ( + "ghcr.io/registrystack/registry-notary@sha256:" + + "c" * 64 + ), + "registry-relay": ( + "ghcr.io/registrystack/registry-relay@sha256:" + + "d" * 64 + ), + }, + } + self.write_candidate_evidence(image_lock, lock) + manifest = ( + self.runner.REPO_ROOT + / "release/manifests/registry-stack-beta-16.yaml" + ) + args = Namespace( + release_manifest=manifest, + image_lock=image_lock, + topology="release-owned", + solmara_source_ref=None, + ) + with patch.object( + candidate_module, "verify_release_authenticity" + ) as authenticate: + candidate = self.runner.candidate_from_args(args) + + self.assertEqual("beta-16", candidate["release_id"]) + authenticate.assert_called_once() + + tagged = candidate_module.git_output( + [ + "show", + ( + "e25f081ce800ade13e892503cc19b96588e081ef:" + "release/manifests/registry-stack-beta-16.yaml" + ), + ], + 1024 * 1024, + ) + released = manifest.read_bytes() + candidate_module.verify_closeout_manifest_transition( + {"status": "released"}, released, tagged + ) + with self.assertRaisesRegex( + candidate_module.CandidateError, "Git binding" + ): + candidate_module.verify_closeout_manifest_transition( + {"status": "released"}, released, released + ) + + real_git_output = candidate_module.git_output + + def released_tag(arguments, max_bytes): + if arguments[0] == "cat-file": + return str(len(released)).encode("ascii") + if arguments[0] == "show": + return released + return real_git_output(arguments, max_bytes) + + with patch.object( + candidate_module, + "git_output", + side_effect=released_tag, + ), patch.object( + candidate_module, "verify_release_authenticity" + ) as authenticate: + with self.assertRaisesRegex( + candidate_module.CandidateError, "Git binding" + ): + self.runner.candidate_from_args(args) + authenticate.assert_not_called() + + real_read = candidate_module.read_regular_file_no_follow + drifted = released + b"# Invalid post-tag manifest drift.\n" + + def drifted_manifest(path, *, max_bytes): + if path == manifest: + return drifted + return real_read(path, max_bytes=max_bytes) + + with patch.object( + candidate_module, + "read_regular_file_no_follow", + side_effect=drifted_manifest, + ), patch.object( + candidate_module, "verify_release_authenticity" + ) as authenticate: + with self.assertRaisesRegex( + candidate_module.CandidateError, "Git binding" + ): + self.runner.candidate_from_args(args) + authenticate.assert_not_called() + + with self.assertRaisesRegex( + candidate_module.CandidateError, "Git binding" + ): + candidate_module.verify_closeout_manifest_transition( + {"status": "released"}, + released + + ( + b"# Any post-tag change beyond the exact status closeout " + b"is rejected.\n" + ), + tagged, + ) def test_native_zitadel_role_claim_drives_scope_profile(self) -> None: token = fake_token(role_claim={"registry-smoke-reader": {"org-1": "active"}}) @@ -323,7 +860,9 @@ def test_output_directory_must_be_empty(self) -> None: def test_run_requires_all_candidate_binding_arguments(self) -> None: with patch.object(sys, "stderr"): with self.assertRaises(SystemExit): - self.runner.parse_args(["run", "--release-id", "1.0.0-rc.1"]) + self.runner.parse_args( + ["run", "--release-manifest", "release/manifests/example.yaml"] + ) def test_helper_never_logs_secret_response_fields(self) -> None: helper = self.runner.HELPER_PATH.read_text(encoding="utf-8") @@ -337,7 +876,7 @@ def test_helper_never_logs_secret_response_fields(self) -> None: def test_readme_states_binding_and_ephemeral_secret_boundaries(self) -> None: readme = (self.runner.CONFIG_DIR / "README.md").read_text(encoding="utf-8") - self.assertIn("does not derive or\ncryptographically verify", readme) + self.assertIn("release-tag, product-version, or image-digest mismatch", readme) self.assertIn("container environment metadata", readme) self.assertIn("container command metadata", readme) self.assertIn("never follow redirects", readme) diff --git a/release/scripts/test_validate_upgrade_exercise.py b/release/scripts/test_validate_upgrade_exercise.py index 4adf52ed3..fdd76418d 100644 --- a/release/scripts/test_validate_upgrade_exercise.py +++ b/release/scripts/test_validate_upgrade_exercise.py @@ -1,17 +1,26 @@ from __future__ import annotations import copy +import contextlib import hashlib import importlib.util +import io import json +import os import sys +import tempfile import unittest from pathlib import Path +from unittest import mock ROOT = Path(__file__).resolve().parents[2] SCRIPT = ROOT / "release" / "scripts" / "validate-upgrade-exercise.py" TEMPLATE = ROOT / "release" / "exercises" / "upgrade-exercise-v1.template.json" +TARGET_COMMIT = "e25f081ce800ade13e892503cc19b96588e081ef" +TARGET_MANIFEST = Path("release/manifests/registry-stack-beta-16.yaml") +SOURCE_COMMIT = "3e587a4f3483b180037b6994fcc4cc0e1d670a16" +SOURCE_MANIFEST = Path("release/manifests/registry-stack-beta-13.yaml") def load_module(): @@ -28,6 +37,52 @@ class UpgradeExerciseValidatorTest(unittest.TestCase): def setUp(self) -> None: self.module = load_module() self.template = json.loads(TEMPLATE.read_text(encoding="utf-8")) + self.candidate_asset_root = Path("/authenticated-candidate-assets") + self.real_load_candidate = self.module.load_candidate + self.source_candidate = { + "release_id": "beta-13", + "version": "0.11.0", + "source_ref": "9b851a606c9cfe298c16e515fbbb5f32c28d98cd", + "source_tag": "v0.11.0", + "tag_target": SOURCE_COMMIT, + "image_lock_sha256": "sha256:" + "b" * 64, + "relay_image": ( + "ghcr.io/registrystack/registry-relay@sha256:" + "b" * 64 + ), + "notary_image": ( + "ghcr.io/registrystack/registry-notary@sha256:" + "b" * 64 + ), + } + self.target_candidate = { + "release_id": "beta-16", + "version": "0.12.2", + "source_ref": "0e76f5ea61f78bbc15d91fcb6e9dfcaa956c3df8", + "source_tag": "v0.12.2", + "tag_target": TARGET_COMMIT, + "image_lock_sha256": "sha256:" + "b" * 64, + "relay_image": ( + "ghcr.io/registrystack/registry-relay@sha256:" + "b" * 64 + ), + "notary_image": ( + "ghcr.io/registrystack/registry-notary@sha256:" + "b" * 64 + ), + } + self.load_candidate = mock.patch.object( + self.module, + "load_candidate", + side_effect=self.authenticated_candidate, + ) + self.load_candidate.start() + self.addCleanup(self.load_candidate.stop) + + def validate_record(self, data, **kwargs): + kwargs.setdefault("candidate_asset_root", self.candidate_asset_root) + return self.module.validate_record(data, **kwargs) + + def authenticated_candidate(self, _manifest_path, lock_path): + if "v0.11.0" in lock_path.name: + return self.source_candidate.copy() + return self.target_candidate.copy() def candidate(self): def replace(value): @@ -49,8 +104,14 @@ def replace(value): return "2026-07-19T12:00:00Z" if value == "": return "v0.11.0" + if value == "": + return "beta-16" + if value == "": + return "0e76f5ea61f78bbc15d91fcb6e9dfcaa956c3df8" + if value == "": + return TARGET_MANIFEST.as_posix() if "VERSION>" in value or "RELEASE_TAG>" in value: - return "v1.0.0" + return "v0.12.2" if "COMMIT>" in value: return "a" * 40 if "SHA256>" in value or "DIGEST>" in value: @@ -63,25 +124,191 @@ def replace(value): record["candidate_independently_verified"] = True for result in record["results"]: result["outcome"] = "passed" + record["source_release"]["source_commit"] = SOURCE_COMMIT + record["target_release"]["source_commit"] = TARGET_COMMIT + manifest = self.module.git_bytes(ROOT, TARGET_COMMIT, TARGET_MANIFEST) + record["target_release_manifest"]["sha256"] = self.module.sha256_bytes(manifest) for product, path in self.module.CONFIG_SCHEMAS.items(): - record["config_schemas"][product]["sha256"] = "sha256:" + hashlib.sha256( - (ROOT / path).read_bytes() - ).hexdigest() + record["config_schemas"][product]["sha256"] = self.module.sha256_bytes( + self.module.git_bytes(ROOT, TARGET_COMMIT, path) + ) + artifacts = record["candidate_artifact_set"]["artifacts"] + artifacts["manifest"] = record["target_release_manifest"]["sha256"] + artifacts["relay_image"] = record["target_release"]["relay_image_digest"] + artifacts["notary_image"] = record["target_release"]["notary_image_digest"] + artifacts["p_release_inputs"] = self.module.release_inputs_sha256( + ROOT, record["target_release"]["source_ref"] + ) + artifacts["t_release_inputs"] = self.module.release_inputs_sha256( + ROOT, TARGET_COMMIT + ) + record["candidate_artifact_set"]["sha256"] = self.module.canonical_sha256(artifacts) return record def test_template_is_valid_preparation_but_not_candidate_evidence(self) -> None: - self.module.validate_record(self.template, allow_template=True) + self.validate_record(self.template, allow_template=True) with self.assertRaisesRegex(self.module.ExerciseError, "not candidate evidence"): - self.module.validate_record(self.template, allow_template=False) + self.validate_record(self.template, allow_template=False) def test_complete_candidate_record_is_valid(self) -> None: - self.module.validate_record(self.candidate(), allow_template=False) + self.module.load_candidate.reset_mock() + self.validate_record( + self.candidate(), allow_template=False, require_all_passed=True + ) + self.module.load_candidate.assert_has_calls( + [ + mock.call( + ROOT / SOURCE_MANIFEST, + ( + self.candidate_asset_root + / "v0.11.0" + / "registryctl-v0.11.0-image-lock.json" + ), + ), + mock.call( + ROOT / TARGET_MANIFEST, + ( + self.candidate_asset_root + / "v0.12.2" + / "registryctl-v0.12.2-image-lock.json" + ), + ), + ] + ) + self.assertEqual(2, self.module.load_candidate.call_count) + + def test_artifact_coordinate_digest_prefixes_are_equivalent(self) -> None: + record = self.candidate() + artifacts = record["candidate_artifact_set"]["artifacts"] + artifacts["relay_image"] = artifacts["relay_image"].removeprefix("sha256:") + record["candidate_artifact_set"]["sha256"] = self.module.canonical_sha256( + artifacts + ) + + self.validate_record( + record, allow_template=False, require_all_passed=True + ) + + def test_artifact_set_digest_prefixes_are_equivalent(self) -> None: + record = self.candidate() + record["candidate_artifact_set"]["sha256"] = record[ + "candidate_artifact_set" + ]["sha256"].removeprefix("sha256:") + + self.validate_record( + record, allow_template=False, require_all_passed=True + ) + + def test_release_input_digest_prefixes_are_equivalent(self) -> None: + record = self.candidate() + artifacts = record["candidate_artifact_set"]["artifacts"] + artifacts["p_release_inputs"] = artifacts["p_release_inputs"].removeprefix( + "sha256:" + ) + artifacts["t_release_inputs"] = artifacts["t_release_inputs"].removeprefix( + "sha256:" + ) + record["candidate_artifact_set"]["sha256"] = self.module.canonical_sha256( + artifacts + ) + + self.validate_record( + record, allow_template=False, require_all_passed=True + ) + + def test_promotion_digest_prefixes_are_equivalent_for_all_pt_sets(self) -> None: + record = self.candidate() + artifacts = record["candidate_artifact_set"]["artifacts"] + for field in ( + "p1_binaries", + "t2_image_inputs", + "p_notary_layouts", + "p_relay_layouts", + "p_release_inputs", + ): + artifacts[field] = artifacts[field].removeprefix("sha256:") + record["candidate_artifact_set"]["sha256"] = self.module.canonical_sha256( + artifacts + ) + + self.validate_record( + record, allow_template=False, require_all_passed=True + ) + + def test_candidate_tag_must_resolve_to_exact_target_commit(self) -> None: + record = self.candidate() + tag_ref = f"refs/tags/{record['target_release']['version']}^{{commit}}" + run = self.module.subprocess.run + + def mismatched_tag(arguments, **kwargs): + if arguments == ["git", "rev-parse", "--verify", tag_ref]: + return mock.Mock( + returncode=0, + stdout=record["target_release"]["source_ref"] + "\n", + ) + return run(arguments, **kwargs) + + with mock.patch.object( + self.module.subprocess, "run", side_effect=mismatched_tag + ) as git_run: + with self.assertRaisesRegex( + self.module.ExerciseError, + "release tag v0.12.2 does not resolve to target_release.source_commit", + ): + self.validate_record(record, allow_template=False) + git_run.assert_any_call( + ["git", "rev-parse", "--verify", "refs/tags/v0.12.2^{commit}"], + cwd=ROOT, + capture_output=True, + text=True, + check=False, + ) def test_candidate_must_be_a_forward_version_upgrade(self) -> None: record = self.candidate() record["source_release"]["version"] = record["target_release"]["version"] with self.assertRaisesRegex(self.module.ExerciseError, "must be newer"): - self.module.validate_record(record, allow_template=False) + self.validate_record(record, allow_template=False) + + def test_prerelease_version_order_uses_semver_precedence(self) -> None: + for lower, higher in ( + ("v1.0.0-rc.1", "v1.0.0-rc.2"), + ("v1.0.0-1", "v1.0.0-2"), + ("v1.0.0-2", "v1.0.0-10"), + ("v1.0.0-alpha", "v1.0.0-beta"), + ("v1.0.0-1", "v1.0.0-alpha"), + ("v1.0.0-rc.2", "v1.0.0"), + ): + with self.subTest(lower=lower, higher=higher): + self.assertLess( + self.module.version_order(lower), + self.module.version_order(higher), + ) + + def test_invalid_versions_are_rejected_by_existing_grammar(self) -> None: + release = { + "source_commit": "a" * 40, + "relay_image_digest": "sha256:" + "b" * 64, + "notary_image_digest": "sha256:" + "b" * 64, + } + for version in ( + "1.0.0", + "v1.0", + "v1.0.0+build", + "v1.0.x", + "v1.0.0-rc..1", + "v1.0.0-.", + "v1.0.0-01", + ): + with self.subTest(version=version): + with self.assertRaisesRegex( + self.module.ExerciseError, "invalid or unsafe" + ): + self.module.validate_release( + {**release, "version": version}, + "source_release", + template=False, + ) def test_candidate_release_identifiers_are_strict(self) -> None: for field, value in ( @@ -93,41 +320,427 @@ def test_candidate_release_identifiers_are_strict(self) -> None: record = self.candidate() record["target_release"][field] = value with self.assertRaisesRegex(self.module.ExerciseError, "invalid or unsafe"): - self.module.validate_record(record, allow_template=False) + self.validate_record(record, allow_template=False) + + def test_source_release_coordinates_are_authenticated(self) -> None: + for field, value in ( + ("version", "v0.10.0"), + ("source_commit", "f" * 40), + ("relay_image_digest", "sha256:" + "e" * 64), + ("notary_image_digest", "sha256:" + "e" * 64), + ): + with self.subTest(field=field): + record = self.candidate() + record["source_release"][field] = value + with self.assertRaisesRegex( + self.module.ExerciseError, + "authenticated source release assets", + ): + self.validate_record(record, allow_template=False) + + def test_source_release_manifest_must_be_unique(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + manifests = root / "release" / "manifests" + manifests.mkdir(parents=True) + manifest = { + "stack": { + "release": "beta-13", + "version": "0.11.0", + "source_tag": "v0.11.0", + } + } + with self.assertRaisesRegex( + self.module.ExerciseError, "exactly one committed release manifest" + ): + self.module.release_manifest_for_version(root, "v0.11.0") + for name in ("registry-stack-beta-13.yaml", "registry-stack-copy.yaml"): + (manifests / name).write_text( + self.module.yaml.safe_dump(manifest), + encoding="utf-8", + ) + with self.assertRaisesRegex( + self.module.ExerciseError, "exactly one committed release manifest" + ): + self.module.release_manifest_for_version(root, "v0.11.0") def test_unknown_field_is_rejected_to_prevent_raw_evidence(self) -> None: record = self.candidate() record["results"][0]["raw_output"] = "Authorization: Bearer secret" with self.assertRaisesRegex(self.module.ExerciseError, "unknown raw_output"): - self.module.validate_record(record, allow_template=False) + self.validate_record(record, allow_template=False) def test_authority_identifier_cannot_contain_a_url_or_subject_data(self) -> None: record = self.candidate() record["topology"]["relay_authorities"][0] = "https://registry.example.test/subject/1" with self.assertRaisesRegex(self.module.ExerciseError, "invalid or unsafe"): - self.module.validate_record(record, allow_template=False) + self.validate_record(record, allow_template=False) - def test_every_required_check_must_pass(self) -> None: - record = self.candidate() - record["results"].pop() - with self.assertRaisesRegex(self.module.ExerciseError, "every required check"): - self.module.validate_record(record, allow_template=False) + def test_failed_and_not_run_results_are_recordable_but_fail_promotion(self) -> None: record = self.candidate() record["results"][0]["outcome"] = "failed" - with self.assertRaisesRegex(self.module.ExerciseError, "must be 'passed'"): - self.module.validate_record(record, allow_template=False) + record["results"][1].update( + {"outcome": "not_run", "observed_at": None, "evidence_label": None, "evidence_sha256": None} + ) + self.validate_record(record, allow_template=False) + with self.assertRaisesRegex(self.module.ExerciseError, "--require-pass"): + self.validate_record( + record, allow_template=False, require_all_passed=True + ) + + def test_discovery_requires_candidate_passes_and_preserves_templates(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + records = Path(temporary) + (records / "template.json").write_text( + json.dumps(self.template), encoding="utf-8" + ) + with mock.patch.object( + sys, + "argv", + [str(SCRIPT), "--discover", str(records)], + ), mock.patch("sys.stdout", new=io.StringIO()): + self.assertEqual(self.module.main(), 0) + + candidate = self.candidate() + candidate["results"][0]["outcome"] = "failed" + (records / "candidate.json").write_text( + json.dumps(candidate), encoding="utf-8" + ) + stderr = io.StringIO() + with mock.patch.object( + sys, + "argv", + [ + str(SCRIPT), + "--discover", + str(records), + "--candidate-asset-root", + str(self.candidate_asset_root), + ], + ), mock.patch("sys.stderr", new=stderr): + self.assertEqual(self.module.main(), 1) + self.assertIn( + "--require-pass requires every check to pass", stderr.getvalue() + ) + + def test_promotion_rejects_product_oci_layout_drift(self) -> None: + for product in ("notary", "relay"): + with self.subTest(product=product): + record = self.candidate() + artifacts = record["candidate_artifact_set"]["artifacts"] + artifacts[f"t_{product}_layouts"] = "sha256:" + "0" * 64 + record["candidate_artifact_set"]["sha256"] = ( + self.module.canonical_sha256(artifacts) + ) + with self.assertRaisesRegex( + self.module.ExerciseError, + f"--require-pass rejects P/T {product} OCI layout drift", + ): + self.validate_record( + record, allow_template=False, require_all_passed=True + ) def test_complete_release_specific_recovery_set_is_required(self) -> None: record = self.candidate() record["recovery_set"].pop() with self.assertRaisesRegex(self.module.ExerciseError, "complete release-specific"): - self.module.validate_record(record, allow_template=False) + self.validate_record(record, allow_template=False) + + def test_candidate_uses_historical_schema_not_ambient_checkout(self) -> None: + record = self.candidate() + notary_schema = self.module.CONFIG_SCHEMAS["registry-notary"] + historical_schema = b'{"historical_target_schema": true}\n' + record["config_schemas"]["registry-notary"]["sha256"] = ( + self.module.sha256_bytes(historical_schema) + ) + ambient = "sha256:" + hashlib.sha256( + (ROOT / notary_schema).read_bytes() + ).hexdigest() + self.assertNotEqual(ambient, record["config_schemas"]["registry-notary"]["sha256"]) + read_git_bytes = self.module.git_bytes + + def historical_git_bytes(root: Path, commit: str, path: Path) -> bytes: + if commit == TARGET_COMMIT and path == notary_schema: + return historical_schema + return read_git_bytes(root, commit, path) + + with mock.patch.object( + self.module, "git_bytes", side_effect=historical_git_bytes + ) as git_bytes: + self.module.validate_config_schemas( + record["config_schemas"], + template=False, + root=ROOT, + target_commit=TARGET_COMMIT, + ) + git_bytes.assert_any_call(ROOT, TARGET_COMMIT, notary_schema) + + def test_manifest_hash_ref_and_artifact_set_drift_are_rejected(self) -> None: + record = self.candidate() + record["target_release_manifest"]["sha256"] = "sha256:" + "0" * 64 + record["candidate_artifact_set"]["artifacts"]["manifest"] = "sha256:" + "0" * 64 + record["candidate_artifact_set"]["sha256"] = self.module.canonical_sha256( + record["candidate_artifact_set"]["artifacts"] + ) + with self.assertRaisesRegex(self.module.ExerciseError, "does not match exact target"): + self.validate_record(record, allow_template=False) + record = self.candidate() + record["target_release"]["source_ref"] = TARGET_COMMIT + record["candidate_artifact_set"]["artifacts"]["p_release_inputs"] = ( + record["candidate_artifact_set"]["artifacts"]["t_release_inputs"] + ) + record["candidate_artifact_set"]["sha256"] = self.module.canonical_sha256( + record["candidate_artifact_set"]["artifacts"] + ) + with self.assertRaisesRegex(self.module.ExerciseError, "identity does not match"): + self.validate_record(record, allow_template=False) + record = self.candidate() + record["candidate_artifact_set"]["artifacts"]["t2_binaries"] = "sha256:" + "0" * 64 + with self.assertRaisesRegex(self.module.ExerciseError, "does not match its artifacts"): + self.validate_record(record, allow_template=False) + + def test_candidate_image_lock_digest_matches_authenticated_asset_bytes( + self, + ) -> None: + with tempfile.TemporaryDirectory() as temporary: + asset_root = Path(temporary) + asset_dir = asset_root / "v0.12.2" + asset_dir.mkdir() + image_lock = asset_dir / "registryctl-v0.12.2-image-lock.json" + image_lock.write_bytes(b'{"authenticated":"release image lock"}') + record = self.candidate() + artifacts = record["candidate_artifact_set"]["artifacts"] + artifacts["image_lock"] = self.module.sha256_bytes( + image_lock.read_bytes() + ) + record["candidate_artifact_set"]["sha256"] = ( + self.module.canonical_sha256(artifacts) + ) + def authenticated_candidate(_manifest_path, lock_path): + metadata = self.authenticated_candidate( + _manifest_path, lock_path + ) + if "v0.12.2" in lock_path.name: + metadata["image_lock_sha256"] = self.module.sha256_bytes( + lock_path.read_bytes() + ) + return metadata + + with mock.patch.object( + self.module, + "load_candidate", + side_effect=authenticated_candidate, + ): + self.validate_record( + record, + allow_template=False, + candidate_asset_root=asset_root, + ) + image_lock.write_bytes(image_lock.read_bytes() + b"\n") + with self.assertRaisesRegex( + self.module.ExerciseError, + "exact authenticated target release image-lock", + ): + self.validate_record( + record, + allow_template=False, + candidate_asset_root=asset_root, + ) + + def test_candidate_evidence_requires_release_asset_directory(self) -> None: + with self.assertRaisesRegex( + self.module.ExerciseError, "--candidate-asset-root" + ): + self.module.validate_record( + self.candidate(), + allow_template=False, + candidate_asset_root=None, + ) + + def test_missing_authentication_tools_or_assets_fail_closed(self) -> None: + for detail in ( + "candidate authenticity verification requires installed cosign", + "required file is unavailable: image lock", + ): + with self.subTest(detail=detail): + with mock.patch.object( + self.module, + "load_candidate", + side_effect=self.module.CandidateError(detail), + ): + with self.assertRaisesRegex( + self.module.ExerciseError, + "could not be authenticated", + ) as caught: + self.validate_record( + self.candidate(), allow_template=False + ) + self.assertNotIn(detail, str(caught.exception)) + + def test_upgrade_consumer_enforces_candidate_to_released_closeout( + self, + ) -> None: + candidate_module = sys.modules["conformance_candidate"] + tagged_candidate = self.module.git_bytes( + ROOT, TARGET_COMMIT, TARGET_MANIFEST + ) + local_released = (ROOT / TARGET_MANIFEST).read_bytes() + cases = ( + ("valid closeout", tagged_candidate, local_released, True), + ("released at tag", local_released, local_released, False), + ( + "post-tag drift", + tagged_candidate, + local_released + b"# Invalid post-tag manifest drift.\n", + False, + ), + ) + for label, tagged, local, valid in cases: + with self.subTest(case=label), tempfile.TemporaryDirectory() as temporary: + asset_root = Path(temporary) + asset_dir = asset_root / "v0.12.2" + asset_dir.mkdir() + image_lock = asset_dir / "registryctl-v0.12.2-image-lock.json" + lock = { + "schema_version": "registryctl.release_image_lock.v1", + "release_tag": "v0.12.2", + "manifest_source_ref": ( + "0e76f5ea61f78bbc15d91fcb6e9dfcaa956c3df8" + ), + "tag_target": TARGET_COMMIT, + "platform": "linux/amd64", + "images": { + "registry-notary": ( + "ghcr.io/registrystack/registry-notary@sha256:" + + "b" * 64 + ), + "registry-relay": ( + "ghcr.io/registrystack/registry-relay@sha256:" + + "b" * 64 + ), + }, + } + lock_bytes = json.dumps(lock).encode("utf-8") + image_lock.write_bytes(lock_bytes) + record = self.candidate() + artifacts = record["candidate_artifact_set"]["artifacts"] + artifacts["image_lock"] = self.module.sha256_bytes(lock_bytes) + record["candidate_artifact_set"]["sha256"] = ( + self.module.canonical_sha256(artifacts) + ) + + @contextlib.contextmanager + def snapshot(_image_lock_path): + yield asset_dir, lock_bytes + + real_git_output = candidate_module.git_output + + def git_output(arguments, max_bytes): + if arguments[0] == "cat-file": + return str(len(tagged)).encode("ascii") + if arguments[0] == "show": + return tagged + return real_git_output(arguments, max_bytes) + + with mock.patch.object( + self.module, + "load_candidate", + side_effect=lambda manifest_path, lock_path: ( + self.authenticated_candidate(manifest_path, lock_path) + if "v0.11.0" in lock_path.name + else self.real_load_candidate(manifest_path, lock_path) + ), + ), mock.patch.object( + candidate_module, + "candidate_asset_snapshot", + snapshot, + ), mock.patch.object( + candidate_module, + "read_regular_file_no_follow", + return_value=local, + ), mock.patch.object( + candidate_module, + "git_output", + side_effect=git_output, + ), mock.patch.object( + candidate_module, + "verify_release_asset_binding", + return_value="c" * 64, + ): + if valid: + self.validate_record( + record, + allow_template=False, + candidate_asset_root=asset_root, + ) + else: + with self.assertRaisesRegex( + self.module.ExerciseError, + "could not be authenticated", + ): + self.validate_record( + record, + allow_template=False, + candidate_asset_root=asset_root, + ) + + def test_real_v0122_release_image_lock_authenticates(self) -> None: + asset_root_value = os.environ.get("REGISTRY_TEST_UPGRADE_ASSET_ROOT") + if not asset_root_value: + self.skipTest( + "REGISTRY_TEST_UPGRADE_ASSET_ROOT is required for real release assets" + ) + asset_root = Path(asset_root_value) + record = self.candidate() + artifacts = record["candidate_artifact_set"]["artifacts"] + for label, version in ( + ("source_release", "v0.11.0"), + ("target_release", "v0.12.2"), + ): + image_lock = ( + asset_root / version / f"registryctl-{version}-image-lock.json" + ) + lock = json.loads(image_lock.read_text(encoding="utf-8")) + record[label]["relay_image_digest"] = lock["images"][ + "registry-relay" + ].split("@", 1)[1] + record[label]["notary_image_digest"] = lock["images"][ + "registry-notary" + ].split("@", 1)[1] + artifacts["relay_image"] = record["target_release"]["relay_image_digest"] + artifacts["notary_image"] = record["target_release"][ + "notary_image_digest" + ] + image_lock = ( + asset_root + / "v0.12.2" + / "registryctl-v0.12.2-image-lock.json" + ) + artifacts["image_lock"] = self.module.sha256_bytes( + image_lock.read_bytes() + ) + record["candidate_artifact_set"]["sha256"] = ( + self.module.canonical_sha256(artifacts) + ) + + with mock.patch.object( + self.module, "load_candidate", self.real_load_candidate + ): + self.validate_record( + record, + allow_template=False, + require_all_passed=True, + candidate_asset_root=asset_root, + ) - def test_candidate_must_use_committed_config_schemas(self) -> None: + def test_topology_requires_one_dedicated_notary_per_relay(self) -> None: record = self.candidate() - record["config_schemas"]["registry-notary"]["sha256"] = "sha256:" + "0" * 64 - with self.assertRaisesRegex(self.module.ExerciseError, "committed schema"): - self.module.validate_record(record, allow_template=False) + record["topology"]["relay_authorities"].append("relay-authority-b") + record["topology"]["authority_pairs"].append( + {"relay": "relay-authority-b", "notary": "notary-authority-a"} + ) + with self.assertRaisesRegex(self.module.ExerciseError, "dedicated Notary"): + self.validate_record(record, allow_template=False) if __name__ == "__main__": diff --git a/release/scripts/validate-upgrade-exercise.py b/release/scripts/validate-upgrade-exercise.py index f7c2cee85..b0a8397bf 100644 --- a/release/scripts/validate-upgrade-exercise.py +++ b/release/scripts/validate-upgrade-exercise.py @@ -7,18 +7,55 @@ import hashlib import json import re +import subprocess import sys from pathlib import Path from typing import Any +import yaml + ROOT = Path(__file__).resolve().parents[2] +SCRIPT_DIR = Path(__file__).resolve().parent +if str(SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPT_DIR)) + +from conformance_candidate import CandidateError, load_candidate # noqa: E402 + + SCHEMA = "registry-stack.upgrade-exercise/v1" SOLMARA_REPOSITORY = "registrystack/solmara-lab" +STACK_REPOSITORY = "registrystack/registry-stack" CONFIG_SCHEMAS = { "registry-relay": Path("schemas/registry-relay.config.schema.json"), "registry-notary": Path("schemas/registry-notary.config.schema.json"), } +RELEASE_INPUTS = ( + Path(".github/workflows/release.yml"), + Path("Cargo.lock"), + Path("Cargo.toml"), + Path("release/docker/Dockerfile.registry-notary"), + Path("release/docker/Dockerfile.registry-relay"), + Path("release/scripts/build-release-binaries.sh"), + Path("release/scripts/build-release-image.sh"), + Path("release/scripts/check-release-relay-features.py"), + Path("release/scripts/compare-release-image-layouts.py"), + Path("release/scripts/registry-release"), + Path("rust-toolchain.toml"), + *CONFIG_SCHEMAS.values(), +) +ARTIFACT_KEYS = { + *(f"{phase}{run}_{kind}" for phase in ("p", "t") for run in (1, 2) + for kind in ("binaries", "image_inputs")), + *(f"{phase}_{product}_layouts" for phase in ("p", "t") + for product in ("notary", "relay")), + "image_lock", + "manifest", + "notary_image", + "relay_image", + "p_release_inputs", + "t_release_inputs", +} REQUIRED_CHECKS = ( "candidate_artifacts_independently_verified", "source_release_ready", @@ -46,7 +83,15 @@ "relay_key_lifecycle_reference", ) SLUG = re.compile(r"^[a-z0-9][a-z0-9._-]{0,127}$") -VERSION = re.compile(r"^v[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$") +SEMVER_NUMBER = r"(?:0|[1-9][0-9]*)" +SEMVER_PRERELEASE_IDENTIFIER = ( + rf"(?:{SEMVER_NUMBER}|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)" +) +VERSION = re.compile( + rf"^v{SEMVER_NUMBER}\.{SEMVER_NUMBER}\.{SEMVER_NUMBER}" + rf"(?:-{SEMVER_PRERELEASE_IDENTIFIER}" + rf"(?:\.{SEMVER_PRERELEASE_IDENTIFIER})*)?$" +) COMMIT = re.compile(r"^[0-9a-f]{40}$") SHA256 = re.compile(r"^(?:sha256:)?[0-9a-f]{64}$") TIMESTAMP = re.compile( @@ -90,11 +135,47 @@ def bounded_string( return value +def sha256_bytes(value: bytes) -> str: + return "sha256:" + hashlib.sha256(value).hexdigest() + + +def sha256_hex(value: str) -> str: + return value.removeprefix("sha256:") + + +def canonical_sha256(value: Any) -> str: + return sha256_bytes(json.dumps(value, sort_keys=True, separators=(",", ":")).encode()) + + +def git_bytes(root: Path, commit: str, path: Path) -> bytes: + result = subprocess.run( + ["git", "show", f"{commit}:{path.as_posix()}"], + cwd=root, + capture_output=True, + check=False, + ) + if result.returncode != 0: + raise ExerciseError(f"{path} does not exist at exact Git object {commit}") + return result.stdout + + +def release_inputs_sha256(root: Path, commit: str) -> str: + return canonical_sha256( + [ + {"path": path.as_posix(), "sha256": sha256_bytes(git_bytes(root, commit, path))} + for path in RELEASE_INPUTS + ] + ) + + def validate_release(value: Any, label: str, *, template: bool) -> None: + keys = {"version", "source_commit", "relay_image_digest", "notary_image_digest"} + if label == "target_release": + keys.update({"release_id", "source_ref"}) release = require_object( value, label, - {"version", "source_commit", "relay_image_digest", "notary_image_digest"}, + keys, ) bounded_string(release["version"], f"{label}.version", VERSION, template=template) bounded_string(release["source_commit"], f"{label}.source_commit", COMMIT, template=template) @@ -104,6 +185,13 @@ def validate_release(value: Any, label: str, *, template: bool) -> None: SHA256, template=template, ) + if label == "target_release": + bounded_string( + release["release_id"], f"{label}.release_id", SLUG, template=template + ) + bounded_string( + release["source_ref"], f"{label}.source_ref", COMMIT, template=template + ) bounded_string( release["notary_image_digest"], f"{label}.notary_image_digest", @@ -112,12 +200,25 @@ def validate_release(value: Any, label: str, *, template: bool) -> None: ) -def version_order(value: str) -> tuple[tuple[int, int, int], bool]: - core, separator, _prerelease = value.removeprefix("v").partition("-") - return tuple(int(part) for part in core.split(".")), not bool(separator) +def version_order( + value: str, +) -> tuple[tuple[int, int, int], tuple[tuple[int, int | str], ...]]: + core, separator, prerelease = value.removeprefix("v").partition("-") + core_order = tuple(int(part) for part in core.split(".")) + if not separator: + return core_order, ((2, 0),) + identifiers: list[tuple[int, int | str]] = [] + for identifier in prerelease.split("."): + if identifier.isdigit(): + identifiers.append((0, int(identifier))) + else: + identifiers.append((1, identifier)) + return core_order, tuple(identifiers) -def validate_config_schemas(value: Any, *, template: bool, root: Path) -> None: +def validate_config_schemas( + value: Any, *, template: bool, root: Path, target_commit: str | None +) -> None: schemas = require_object(value, "config_schemas", set(CONFIG_SCHEMAS)) for product, expected_path in CONFIG_SCHEMAS.items(): entry = require_object( @@ -131,14 +232,52 @@ def validate_config_schemas(value: Any, *, template: bool, root: Path) -> None: entry["sha256"], f"config_schemas.{product}.sha256", SHA256, template=template ) if not template: - actual = hashlib.sha256((root / expected_path).read_bytes()).hexdigest() - if digest.removeprefix("sha256:") != actual: + assert target_commit is not None + actual = sha256_bytes(git_bytes(root, target_commit, expected_path)) + if sha256_hex(digest) != sha256_hex(actual): raise ExerciseError( - f"config_schemas.{product}.sha256 does not match the committed schema" + f"config_schemas.{product}.sha256 does not match the exact target Git object" ) +def validate_artifact_set(value: Any, record: dict[str, Any], *, template: bool) -> None: + artifact_set = require_object(value, "candidate_artifact_set", {"sha256", "artifacts"}) + bounded_string( + artifact_set["sha256"], + "candidate_artifact_set.sha256", + SHA256, + template=template, + ) + artifacts = require_object( + artifact_set["artifacts"], "candidate_artifact_set.artifacts", ARTIFACT_KEYS + ) + for name, digest in artifacts.items(): + bounded_string( + digest, + f"candidate_artifact_set.artifacts.{name}", + SHA256, + template=template, + ) + if template: + return + expected = { + "manifest": record["target_release_manifest"]["sha256"], + "relay_image": record["target_release"]["relay_image_digest"], + "notary_image": record["target_release"]["notary_image_digest"], + } + if any( + sha256_hex(artifacts[name]) != sha256_hex(digest) + for name, digest in expected.items() + ): + raise ExerciseError("candidate_artifact_set does not match target release coordinates") + if sha256_hex(artifact_set["sha256"]) != sha256_hex(canonical_sha256(artifacts)): + raise ExerciseError("candidate_artifact_set.sha256 does not match its artifacts") + + def validate_topology(value: Any, *, template: bool) -> None: + # The public record bounds and cross-checks topology coordinates without + # ingesting the private Solmara execution packet. Independent review owns + # the binding between these coordinates and retained execution evidence. topology = require_object( value, "topology", @@ -179,7 +318,7 @@ def validate_topology(value: Any, *, template: bool) -> None: if not template and (pair_relay not in relay or pair_notary not in notary): raise ExerciseError("topology.authority_pairs references an undeclared authority") if pair_relay in seen_relay or pair_notary in seen_notary: - raise ExerciseError("topology.authority_pairs must be one-to-one") + raise ExerciseError("each Relay must have exactly one dedicated Notary authority") seen_relay.add(pair_relay) seen_notary.add(pair_notary) if not template and (seen_relay != relay or seen_notary != notary): @@ -243,26 +382,30 @@ def validate_results(value: Any, *, template: bool) -> None: ) if check_id not in REQUIRED_CHECKS: raise ExerciseError(f"results[{index}].check_id is not a required check") - expected_outcome = "not_run" if template else "passed" - if result["outcome"] != expected_outcome: - raise ExerciseError( - f"results[{index}].outcome must be {expected_outcome!r} for this record kind" + outcome = result["outcome"] + if outcome not in {"passed", "failed", "not_run"} or template and outcome != "not_run": + raise ExerciseError(f"results[{index}].outcome is invalid for this record kind") + if not template and outcome == "not_run": + if any(result[field] is not None for field in ( + "observed_at", "evidence_label", "evidence_sha256" + )): + raise ExerciseError("not_run result evidence fields must be null") + else: + bounded_string( + result["observed_at"], f"results[{index}].observed_at", TIMESTAMP, template=template + ) + bounded_string( + result["evidence_label"], + f"results[{index}].evidence_label", + SLUG, + template=template, + ) + bounded_string( + result["evidence_sha256"], + f"results[{index}].evidence_sha256", + SHA256, + template=template, ) - bounded_string( - result["observed_at"], f"results[{index}].observed_at", TIMESTAMP, template=template - ) - bounded_string( - result["evidence_label"], - f"results[{index}].evidence_label", - SLUG, - template=template, - ) - bounded_string( - result["evidence_sha256"], - f"results[{index}].evidence_sha256", - SHA256, - template=template, - ) if check_id in seen: raise ExerciseError(f"duplicate result check_id: {check_id}") seen.add(check_id) @@ -270,7 +413,206 @@ def validate_results(value: Any, *, template: bool) -> None: raise ExerciseError("results must contain every required check exactly once") -def validate_record(data: Any, *, allow_template: bool, root: Path = ROOT) -> None: +def validate_target_binding(record: dict[str, Any], root: Path) -> None: + target = record["target_release"] + source_ref = target["source_ref"] + target_commit = target["source_commit"] + tag_ref = f"refs/tags/{target['version']}^{{commit}}" + tag_target = subprocess.run( + ["git", "rev-parse", "--verify", tag_ref], + cwd=root, capture_output=True, text=True, check=False, + ) + if tag_target.returncode != 0 or tag_target.stdout.strip() != target_commit: + raise ExerciseError( + f"release tag {target['version']} does not resolve to target_release.source_commit" + ) + for value, label in ((source_ref, "source_ref"), (target_commit, "source_commit")): + resolved = subprocess.run( + ["git", "rev-parse", "--verify", f"{value}^{{commit}}"], + cwd=root, capture_output=True, text=True, check=False, + ) + if resolved.returncode != 0 or resolved.stdout.strip() != value: + raise ExerciseError(f"target_release.{label} does not resolve exactly") + if subprocess.run( + ["git", "merge-base", "--is-ancestor", source_ref, target_commit], + cwd=root, capture_output=True, check=False, + ).returncode != 0: + raise ExerciseError("target_release.source_ref is not an ancestor of source_commit") + + coordinate = record["target_release_manifest"] + manifest_bytes = git_bytes(root, target_commit, Path(coordinate["path"])) + if sha256_hex(coordinate["sha256"]) != sha256_hex(sha256_bytes(manifest_bytes)): + raise ExerciseError("target_release_manifest.sha256 does not match exact target") + try: + manifest = yaml.safe_load(manifest_bytes) + except yaml.YAMLError as error: + raise ExerciseError(f"target release manifest is invalid YAML: {error}") from error + stack = manifest.get("stack") if isinstance(manifest, dict) else None + expected = { + "release": target["release_id"], + "version": target["version"].removeprefix("v"), + "source_repo": STACK_REPOSITORY, + "source_ref": source_ref, + "source_tag": target["version"], + } + if not isinstance(stack, dict) or any(str(stack.get(key)) != value for key, value in expected.items()): + raise ExerciseError("target release manifest identity does not match target_release") + artifacts = manifest.get("artifacts") + if not isinstance(artifacts, dict) or not artifacts or any( + str(version) != expected["version"] for version in artifacts.values() + ): + raise ExerciseError("target release manifest artifact versions do not match target_release") + + artifact_set = record["candidate_artifact_set"]["artifacts"] + for field, commit in (("p_release_inputs", source_ref), ("t_release_inputs", target_commit)): + if sha256_hex(artifact_set[field]) != sha256_hex( + release_inputs_sha256(root, commit) + ): + raise ExerciseError(f"candidate_artifact_set.artifacts.{field} does not match exact Git object") + + +def validate_release_asset_bindings( + record: dict[str, Any], + root: Path, + candidate_asset_root: Path | None, +) -> None: + source = record["source_release"] + source_manifest_path = release_manifest_for_version(root, source["version"]) + load_authenticated_release( + source, + "source release", + source_manifest_path, + candidate_asset_root, + ) + + target = record["target_release"] + candidate = load_authenticated_release( + target, + "target release", + root / record["target_release_manifest"]["path"], + candidate_asset_root, + ) + artifacts = record["candidate_artifact_set"]["artifacts"] + expected = { + "release_id": target["release_id"], + "source_ref": target["source_ref"], + "image_lock_sha256": artifacts["image_lock"], + } + if sha256_hex(candidate["image_lock_sha256"]) != sha256_hex( + expected["image_lock_sha256"] + ): + raise ExerciseError( + "candidate_artifact_set.artifacts.image_lock does not match " + "the exact authenticated target release image-lock asset" + ) + for field in ("release_id", "source_ref"): + if candidate[field] != expected[field]: + raise ExerciseError( + "authenticated target release assets do not match target release coordinates" + ) + + +def release_manifest_for_version(root: Path, version: str) -> Path: + matches: list[Path] = [] + manifest_dir = root / "release" / "manifests" + for path in sorted(manifest_dir.glob("registry-stack-*.yaml")): + try: + manifest = yaml.safe_load(path.read_bytes()) + except (OSError, yaml.YAMLError): + raise ExerciseError( + "release manifests could not be inspected safely" + ) from None + stack = manifest.get("stack") if isinstance(manifest, dict) else None + if ( + isinstance(stack, dict) + and stack.get("source_tag") == version + and str(stack.get("version")) == version.removeprefix("v") + ): + matches.append(path) + if len(matches) != 1: + raise ExerciseError( + f"source release {version} must identify exactly one committed release manifest" + ) + return matches[0] + + +def load_authenticated_release( + release: dict[str, Any], + label: str, + manifest_path: Path, + candidate_asset_root: Path | None, +) -> dict[str, Any]: + if candidate_asset_root is None: + raise ExerciseError( + "candidate evidence requires --candidate-asset-root for release authentication" + ) + version = release["version"] + candidate_asset_dir = candidate_asset_root.expanduser() / version + image_lock_path = ( + candidate_asset_dir / f"registryctl-{version}-image-lock.json" + ) + try: + candidate = load_candidate(manifest_path, image_lock_path) + except (CandidateError, OSError): + raise ExerciseError( + f"{label} assets could not be authenticated" + ) from None + expected = { + "version": version.removeprefix("v"), + "source_tag": version, + "tag_target": release["source_commit"], + "relay_image": "ghcr.io/registrystack/registry-relay@sha256:" + + sha256_hex(release["relay_image_digest"]), + "notary_image": "ghcr.io/registrystack/registry-notary@sha256:" + + sha256_hex(release["notary_image_digest"]), + } + for field in expected: + if candidate[field] != expected[field]: + raise ExerciseError( + f"authenticated {label} assets do not match {label} coordinates" + ) + return candidate + + +def require_pass(record: dict[str, Any]) -> None: + # Private inventories and OCI layouts remain outside the public record. + # Promotion therefore requires explicit freeze and independent-review + # attestations, then enforces that every recorded result passed and that + # all redaction-safe P/T identities agree. + if not record["candidate_frozen"] or not record["candidate_independently_verified"]: + raise ExerciseError("--require-pass requires both candidate attestations") + if any(result["outcome"] != "passed" for result in record["results"]): + raise ExerciseError("--require-pass requires every check to pass") + artifacts = record["candidate_artifact_set"]["artifacts"] + for kind in ("binaries", "image_inputs"): + if ( + len({ + sha256_hex(artifacts[f"{phase}{run}_{kind}"]) + for phase in ("p", "t") + for run in (1, 2) + }) + != 1 + ): + raise ExerciseError(f"--require-pass rejects P/T {kind} drift") + for product in ("notary", "relay"): + if sha256_hex(artifacts[f"p_{product}_layouts"]) != sha256_hex( + artifacts[f"t_{product}_layouts"] + ): + raise ExerciseError(f"--require-pass rejects P/T {product} OCI layout drift") + if sha256_hex(artifacts["p_release_inputs"]) != sha256_hex( + artifacts["t_release_inputs"] + ): + raise ExerciseError("--require-pass rejects P/T release-input drift") + + +def validate_record( + data: Any, + *, + allow_template: bool, + require_all_passed: bool = False, + root: Path = ROOT, + candidate_asset_root: Path | None = None, +) -> None: record = require_object( data, "record", @@ -281,10 +623,11 @@ def validate_record(data: Any, *, allow_template: bool, root: Path = ROOT) -> No "recorded_at", "source_release", "target_release", - "target_release_manifest_sha256", + "target_release_manifest", "candidate_frozen", "candidate_independently_verified", "config_schemas", + "candidate_artifact_set", "topology", "recovery_set", "results", @@ -308,37 +651,85 @@ def validate_record(data: Any, *, allow_template: bool, root: Path = ROOT) -> No record["source_release"]["version"] ): raise ExerciseError("target_release.version must be newer than source_release.version") - bounded_string( - record["target_release_manifest_sha256"], - "target_release_manifest_sha256", - SHA256, + manifest = require_object( + record["target_release_manifest"], "target_release_manifest", {"path", "sha256"} + ) + if not (template and PLACEHOLDER.fullmatch(str(manifest["path"]))): + path = Path(str(manifest["path"])) + if path.is_absolute() or ".." in path.parts or not path.as_posix().startswith("release/manifests/"): + raise ExerciseError("target_release_manifest.path must be a safe release manifest path") + bounded_string(manifest["sha256"], "target_release_manifest.sha256", SHA256, template=template) + for field in ("candidate_frozen", "candidate_independently_verified"): + if not isinstance(record[field], bool) or template and record[field]: + raise ExerciseError(f"{field} must be false in a template and boolean in evidence") + validate_config_schemas( + record["config_schemas"], template=template, + root=root, + target_commit=None if template else record["target_release"]["source_commit"], ) - expected_bool = not template - if record["candidate_frozen"] is not expected_bool: - raise ExerciseError(f"candidate_frozen must be {str(expected_bool).lower()}") - if record["candidate_independently_verified"] is not expected_bool: - raise ExerciseError( - f"candidate_independently_verified must be {str(expected_bool).lower()}" - ) - validate_config_schemas(record["config_schemas"], template=template, root=root) + validate_artifact_set(record["candidate_artifact_set"], record, template=template) validate_topology(record["topology"], template=template) validate_recovery_set(record["recovery_set"], template=template) validate_results(record["results"], template=template) + if not template: + validate_target_binding(record, root) + validate_release_asset_bindings(record, root, candidate_asset_root) + if require_all_passed: + require_pass(record) def main() -> int: parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("record", type=Path) + parser.add_argument("record", nargs="?", type=Path) parser.add_argument( "--template", action="store_true", help="validate a preparation template; templates never count as candidate evidence", ) + parser.add_argument( + "--require-pass", + action="store_true", + help=( + "require candidate attestations, passed checks, and matching P/T " + "identities; private evidence remains independently reviewed" + ), + ) + parser.add_argument("--discover", type=Path) + parser.add_argument( + "--candidate-asset-root", + type=Path, + help=( + "root containing one downloaded and authenticated asset directory " + "per source and target version" + ), + ) args = parser.parse_args() try: + if args.discover: + records = sorted(args.discover.glob("*.json")) + if not records: + raise ExerciseError("--discover found no JSON records") + for path in records: + data = json.loads(path.read_text(encoding="utf-8")) + template = data.get("record_kind") == "template" + validate_record( + data, + allow_template=template, + require_all_passed=not template, + candidate_asset_root=args.candidate_asset_root, + ) + print(f"upgrade exercise discovery passed: {len(records)} record(s)") + return 0 + if args.record is None: + raise ExerciseError("a record path is required") data = json.loads(args.record.read_text(encoding="utf-8")) - validate_record(data, allow_template=args.template) + validate_record( + data, + allow_template=args.template, + require_all_passed=args.require_pass, + candidate_asset_root=args.candidate_asset_root, + ) except (ExerciseError, OSError, json.JSONDecodeError) as error: print(f"upgrade exercise validation failed: {error}", file=sys.stderr) return 1 diff --git a/schemas/registry-notary.config.schema.json b/schemas/registry-notary.config.schema.json index e7705e411..fef2cacd7 100644 --- a/schemas/registry-notary.config.schema.json +++ b/schemas/registry-notary.config.schema.json @@ -641,7 +641,7 @@ }, "DeploymentWaiverConfig": { "additionalProperties": false, - "description": "One operator-configured waiver.\n\nA waiver names exactly one finding id, a free-text reason, and a mandatory\nexpiry date (`YYYY-MM-DD`). Reasons must not contain secrets.", + "description": "One operator-configured waiver.\n\nA waiver names exactly one finding id, a required operator reference, an\noptional summary, and a mandatory expiry date (`YYYY-MM-DD`). The shared\noperations contract validates metadata before it can reach posture or logs.", "properties": { "expires": { "type": "string" @@ -649,17 +649,53 @@ "finding": { "type": "string" }, - "reason": { - "type": "string" + "reference": { + "$ref": "#/$defs/DeploymentWaiverReference" + }, + "summary": { + "$ref": "#/$defs/DeploymentWaiverSummary" } }, "required": [ "finding", - "reason", + "reference", "expires" ], "type": "object" }, + "DeploymentWaiverReference": { + "maxLength": 128, + "minLength": 1, + "not": { + "pattern": "^(?:[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]:)?(?:[Bb][Ee][Aa][Rr][Ee][Rr]|[Bb][Aa][Ss][Ii][Cc]):[A-Za-z0-9._:-]+$" + }, + "pattern": "^(?!.*\\.\\.)[A-Za-z0-9._:-]+$", + "type": "string" + }, + "DeploymentWaiverSummary": { + "$comment": "Schema acceptance does not replace validate_deployment_waiver_metadata.", + "allOf": [ + { + "not": { + "pattern": "[\\u0000-\\u001F\\u007F-\\u009F]" + } + }, + { + "not": { + "pattern": "^[\\u0009-\\u000D\\u0020\\u0085\\u00A0\\u1680\\u2000-\\u200A\\u2028\\u2029\\u202F\\u205F\\u3000]" + } + }, + { + "not": { + "pattern": "[\\u0009-\\u000D\\u0020\\u0085\\u00A0\\u1680\\u2000-\\u200A\\u2028\\u2029\\u202F\\u205F\\u3000]$" + } + } + ], + "description": "Structurally valid deployment-waiver summary. Contextual authorization-value and private-key marker exclusions require semantic producer validation.", + "maxLength": 256, + "minLength": 1, + "type": "string" + }, "DisclosureConfig": { "additionalProperties": false, "properties": { diff --git a/schemas/registry-relay.config.schema.json b/schemas/registry-relay.config.schema.json index f0ad5514b..c4f022660 100644 --- a/schemas/registry-relay.config.schema.json +++ b/schemas/registry-relay.config.schema.json @@ -1446,7 +1446,7 @@ "default": null }, "waivers": { - "description": "Per-deployment waivers. Each names one finding id, a free-text reason,\nand a mandatory expiry date. Expired waivers stop suppressing their\nfinding and raise `deployment.waiver_expired`.", + "description": "Per-deployment waivers. Each names one finding id, a required operator\nreference, an optional summary, and a mandatory expiry date. Expired\nwaivers stop suppressing their finding and raise\n`deployment.waiver_expired`.", "items": { "$ref": "#/$defs/DeploymentWaiverConfig" }, @@ -1507,7 +1507,7 @@ }, "DeploymentWaiverConfig": { "additionalProperties": false, - "description": "One declared waiver. `expires` is an ISO 8601 `YYYY-MM-DD` date; format is\nvalidated at load time. Reasons must not carry secrets.", + "description": "One declared waiver. `expires` is an ISO 8601 `YYYY-MM-DD` date; format is\nvalidated at load time. The reference and optional summary are validated by\nthe shared operations contract before either can reach posture or logs.", "properties": { "expires": { "type": "string" @@ -1515,17 +1515,53 @@ "finding": { "type": "string" }, - "reason": { - "type": "string" + "reference": { + "$ref": "#/$defs/DeploymentWaiverReference" + }, + "summary": { + "$ref": "#/$defs/DeploymentWaiverSummary" } }, "required": [ "finding", - "reason", + "reference", "expires" ], "type": "object" }, + "DeploymentWaiverReference": { + "maxLength": 128, + "minLength": 1, + "not": { + "pattern": "^(?:[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]:)?(?:[Bb][Ee][Aa][Rr][Ee][Rr]|[Bb][Aa][Ss][Ii][Cc]):[A-Za-z0-9._:-]+$" + }, + "pattern": "^(?!.*\\.\\.)[A-Za-z0-9._:-]+$", + "type": "string" + }, + "DeploymentWaiverSummary": { + "$comment": "Schema acceptance does not replace validate_deployment_waiver_metadata.", + "allOf": [ + { + "not": { + "pattern": "[\\u0000-\\u001F\\u007F-\\u009F]" + } + }, + { + "not": { + "pattern": "^[\\u0009-\\u000D\\u0020\\u0085\\u00A0\\u1680\\u2000-\\u200A\\u2028\\u2029\\u202F\\u205F\\u3000]" + } + }, + { + "not": { + "pattern": "[\\u0009-\\u000D\\u0020\\u0085\\u00A0\\u1680\\u2000-\\u200A\\u2028\\u2029\\u202F\\u205F\\u3000]$" + } + } + ], + "description": "Structurally valid deployment-waiver summary. Contextual authorization-value and private-key marker exclusions require semantic producer validation.", + "maxLength": 256, + "minLength": 1, + "type": "string" + }, "DisclosureControlConfig": { "additionalProperties": false, "description": "Disclosure control settings per aggregate. Defaults to\n`min_group_size: 5`, `suppression: omit`.",