From aa7939ca9a66790a4bce0170fa2befbd8e2d4d1e Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 23 Jul 2026 11:29:30 +0700 Subject: [PATCH 01/58] fix(registryctl): separate evidence from programme decisions Signed-off-by: Jeremi Joslin --- crates/registryctl/CHANGELOG.md | 9 + .../src/project_authoring/fixtures.rs | 2 +- .../src/project_authoring/tests.rs | 7 +- .../fixtures/project-authoring-journeys.yaml | 4 +- .../eligibility/fixtures/no-match.yaml | 5 +- .../{eligible.yaml => source-approved.yaml} | 7 +- .../integrations/eligibility/integration.yaml | 2 +- .../custom-system/registry-stack.yaml | 6 +- .../integrations/health-record/adapter.rhai | 35 +++- .../health-record/fixtures/match.yaml | 14 +- .../health-record/fixtures/no-enrollment.yaml | 46 ++++ .../health-record/fixtures/no-match.yaml | 11 +- .../health-record/fixtures/partial.yaml | 50 +++++ .../health-record/integration.yaml | 13 +- .../dhis2-script/registry-stack.yaml | 36 ++-- .../project-authoring/dhis2-tracker/README.md | 38 +++- .../integrations/health-record/adapter.rhai | 35 +++- .../health-record/fixtures/match.yaml | 14 +- .../health-record/fixtures/no-enrollment.yaml | 46 ++++ .../health-record/fixtures/no-match.yaml | 11 +- .../health-record/fixtures/partial.yaml | 50 +++++ .../health-record/integration.yaml | 13 +- .../dhis2-tracker/registry-stack.yaml | 38 ++-- .../snapshot-exact/README.md | 8 + .../snapshot-exact/entities/people.yaml | 4 +- .../snapshot-exact/environments/local.yaml | 2 +- .../person-snapshot/fixtures/match.yaml | 8 +- .../person-snapshot/fixtures/no-match.yaml | 4 +- .../person-snapshot/integration.yaml | 2 +- .../snapshot-exact/registry-stack.yaml | 12 +- .../entities/people.yaml | 4 +- .../environments/local.yaml | 2 +- .../person-snapshot/fixtures/match.yaml | 8 +- .../person-snapshot/fixtures/no-match.yaml | 4 +- .../person-snapshot/integration.yaml | 2 +- .../snapshot-with-records/registry-stack.yaml | 12 +- crates/registryctl/tests/project_authoring.rs | 198 +++++++++++++++--- .../generate-project-starters.test.mjs | 2 +- docs/site/src/content/docs/changelog.mdx | 6 + .../content/docs/explanation/architecture.mdx | 37 +++- .../disclosure-modes-and-computed-answers.mdx | 62 ++++-- .../docs/explanation/integration-patterns.mdx | 28 ++- .../docs/explanation/records-stay-home.mdx | 44 ++-- .../src/content/docs/reference/glossary.mdx | 16 +- .../src/content/docs/start/when-to-use.mdx | 10 +- .../tutorials/author-registry-project.mdx | 27 ++- .../configure-project-script-adapter.mdx | 31 +++ ...igure-project-snapshot-materialization.mdx | 19 +- .../generated/project-authoring-journeys.json | 14 +- .../src/data/generated/project-starters.json | 8 +- products/notary/README.md | 5 + products/notary/docs/README.md | 6 +- products/notary/docs/architecture-overview.md | 24 ++- products/notary/docs/client-sdk-guide.md | 4 + .../notary/docs/notary-capability-matrix.md | 27 ++- .../notary/docs/notary-scenario-patterns.md | 17 +- .../docs/self-attestation-operator-guide.md | 11 +- .../docs/source-claim-modeling-guide.md | 67 +++++- release/conformance/integrations/README.md | 10 +- .../dhis2-tracker-2.41.9.profile.json | 3 + release/scripts/integration-e2-runner.py | 3 + release/scripts/test_integration_e2_runner.py | 22 ++ 62 files changed, 1005 insertions(+), 260 deletions(-) rename crates/registryctl/tests/fixtures/project-authoring/custom-system/integrations/eligibility/fixtures/{eligible.yaml => source-approved.yaml} (69%) create mode 100644 crates/registryctl/tests/fixtures/project-authoring/dhis2-script/integrations/health-record/fixtures/no-enrollment.yaml create mode 100644 crates/registryctl/tests/fixtures/project-authoring/dhis2-script/integrations/health-record/fixtures/partial.yaml create mode 100644 crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/integrations/health-record/fixtures/no-enrollment.yaml create mode 100644 crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/integrations/health-record/fixtures/partial.yaml diff --git a/crates/registryctl/CHANGELOG.md b/crates/registryctl/CHANGELOG.md index 86d7c31b7..1b77332e5 100644 --- a/crates/registryctl/CHANGELOG.md +++ b/crates/registryctl/CHANGELOG.md @@ -6,6 +6,15 @@ 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 consuming programme 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. + ## [0.12.2] - 2026-07-20 - No user-visible Registryctl changes. This release fixes forward from the 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..d733cc962 100644 --- a/crates/registryctl/src/project_authoring/tests.rs +++ b/crates/registryctl/src/project_authoring/tests.rs @@ -782,11 +782,14 @@ outputs: &loaded, &json!({ "purpose": "household-support-screening", - "claims": ["household-category", "household-eligible"], + "claims": ["household-category", "source-household-approval-decision"], }), ) .expect("claims from both same-purpose services are valid"); - assert_eq!(claims, ["household-category", "household-eligible"]); + assert_eq!( + claims, + ["household-category", "source-household-approval-decision"] + ); } #[test] diff --git a/crates/registryctl/tests/fixtures/project-authoring-journeys.yaml b/crates/registryctl/tests/fixtures/project-authoring-journeys.yaml index 1ed14ab04..93288cb2a 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 downstream programme 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..dcbc5664a 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. A consuming programme applies its own reviewed rules. For example, a + programme 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/snapshot-exact/README.md b/crates/registryctl/tests/fixtures/project-authoring/snapshot-exact/README.md index ce4b7c562..c7770fbaf 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,11 @@ 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 consuming programme, not this project, combines that evidence with its own +eligibility, prioritization, payment, or workflow 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/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/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..614786748 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-consuming-programme evidence boundary normative for 1.0 project + authoring. Updated Notary guidance and registryctl examples to keep reusable evidence separate + from eligibility, prioritization, 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..3a7b4b947 100644 --- a/docs/site/src/content/docs/explanation/architecture.mdx +++ b/docs/site/src/content/docs/explanation/architecture.mdx @@ -64,6 +64,30 @@ updated policy documents without touching deployment config. topologies." /> +## Evidence and programme decisions + +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 + programme 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 + programme rule. +4. A consuming programme owns eligibility, prioritization, workflow, and action. It combines the + evidence with its own rules and remains accountable for the resulting decision. + +These are three different policy categories: Relay source-access and adaptation policy, Notary +evidence authorization and disclosure policy, and consuming programme decision and action policy. +Project authors must keep them separate even when one deployment operates every component. + +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 +102,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. A consuming programme evaluates eligibility, priority, workflow, and action from the returned + evidence. Those programme decisions are 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..dca4be126 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 programme'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,34 @@ 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 programme 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 consuming programme separately owns +eligibility, prioritization, workflow, and action policy. + +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 +programme eligibility engine. The consuming programme can combine 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 +105,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 +116,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..64ed43ac8 100644 --- a/docs/site/src/content/docs/explanation/integration-patterns.mdx +++ b/docs/site/src/content/docs/explanation/integration-patterns.mdx @@ -64,6 +64,31 @@ 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 consuming programme +owns eligibility, prioritization, 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. The consuming programme can then 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 +201,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..787259ca0 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,14 @@ 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, and the consuming programme decides what to do.** 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 +51,10 @@ flowchart LR notary -. records .-> audit key -. signs .-> notary end - caller["Caller / verifier"] + caller["Consuming programme / verifier"] 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 +75,12 @@ 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 consuming programme owns eligibility, prioritization, workflow, and action policy. +Purpose-bound authorization can restrict an evidence request without changing which component owns +the programme decision. + ## What stays home - Source data is read in place: Relay reads sources as batch snapshots or table scans; @@ -101,7 +108,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 +155,12 @@ 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. The consuming programme 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 +221,14 @@ 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 programme decision engine: Notary returns governed evidence. The consuming + programme owns eligibility, prioritization, workflow, and action 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..5b239f9b0 100644 --- a/docs/site/src/content/docs/reference/glossary.mdx +++ b/docs/site/src/content/docs/reference/glossary.mdx @@ -111,8 +111,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 a consuming programme's eligibility 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 +135,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 consuming programme 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 programme policy.
JSON-LD
JSON-based linked data format. W3C recommendation (JSON-LD 1.1). Used for catalog, policy, and CCCEV render output.
@@ -192,6 +192,9 @@ Product names are always in English, including on future translated pages.
policy hash
Lowercase `sha256:` digest that binds a governed evidence policy identity to canonical policy bytes or an externally supplied policy artifact. Manifest verifies it when an inline evidence pack policy object is present; Relay records the selected hash in governed PDP audit provenance.
+
programme decision
+
An eligibility, prioritization, workflow, or action decision made by a consuming programme from evidence and its own rules. Registry Relay and Registry Notary do not own this policy or decision.
+
Protected Registry APIs
Registry Stack runtime pattern for exposing existing registry source data through scoped, read-only HTTP routes with authentication, authorization, metadata, and audit. Registry Relay implements this pattern.
@@ -240,6 +243,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/start/when-to-use.mdx b/docs/site/src/content/docs/start/when-to-use.mdx index b0b1b3330..8a7e8760e 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,9 @@ 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. A consuming programme uses that + evidence under its own eligibility and action policy. {/* SVG diagram. Essential labels are restated in the bullets above and the sentence below. */}
@@ -92,6 +93,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 + consuming programme combines that evidence with its own decision rules. 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..b3a7f40e7 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,30 @@ 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 consuming programme keeps its eligibility, +prioritization, 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 programme 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 programme reuse the evidence without importing the first programme's + eligibility, 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. + +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 +245,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 + consuming programme 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..c66fe2b36 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,33 @@ evidence only; any reviewed source system can use the same script capability and +## Keep DHIS2 evidence separate from programme 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. The consuming programme 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 consuming programme 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 +221,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..3ff3d0595 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,15 @@ 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 consuming programme decision such as +`benefits-eligible`. The consuming programme owns the 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 +168,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/data/generated/project-authoring-journeys.json b/docs/site/src/data/generated/project-authoring-journeys.json index a9ec48403..d9d6bcd56 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 downstream programme 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..221687105 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 downstream programme 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/README.md b/products/notary/README.md index 243c8a17b..614ce104d 100644 --- a/products/notary/README.md +++ b/products/notary/README.md @@ -24,6 +24,11 @@ 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 consuming programme owns eligibility, entitlement, prioritization, +referral, payment, and workflow decisions. 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 programme 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..fe4f7f1f9 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 consuming programme owns eligibility, prioritization, +workflow, and action policy; CEL is not a general-purpose programme decision +engine. diff --git a/products/notary/docs/architecture-overview.md b/products/notary/docs/architecture-overview.md index 03797638c..e9d0f9926 100644 --- a/products/notary/docs/architecture-overview.md +++ b/products/notary/docs/architecture-overview.md @@ -28,18 +28,20 @@ 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 consuming programme applies its eligibility, prioritization, workflow, + or action policy to the returned evidence. +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] + Programme[Consuming programme
eligibility, workflow, action] --> Notary[Registry Notary
evidence, disclosure, issuance] Notary --> Relay[Registry Relay
acquisition, normalization, outputs] Relay --> Source[(Registry source)] Relay --> Notary - Notary --> Caller + Notary --> Programme ``` Notary never treats a Relay failure as `no_match`. Ambiguity, denial, source, @@ -50,7 +52,7 @@ 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 +separate Notary authorization decision, not a programme decision. Where a delegated relationship is proved by Relay, Notary still pins the exact consultation and performs all authorization checks before invoking it. @@ -58,6 +60,12 @@ checks before invoking it. 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 +caller authentication, purpose and legal basis, consent policy, reusable +evidence claims, CEL evidence derivation, disclosure, credential profiles, +signing, and issuance. The consuming programme owns eligibility, entitlement, +prioritization, referral, payment, and workflow 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 programme policy. diff --git a/products/notary/docs/client-sdk-guide.md b/products/notary/docs/client-sdk-guide.md index 21726635d..d7257eb95 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 consuming programme's +eligibility rule. + ```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..6da391c5f 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 | +| Programme administrator | Define eligibility policy, evidence requirements, and acceptable issuers | Social protection ministry, agriculture programme team | | 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,44 @@ 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 | Consumes evidence and owns eligibility, prioritization, workflow, and action decisions | | 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 consuming programme makes any +eligibility, entitlement, payment, referral, or workflow decision. A Notary +claim may attest a source-owned decision, but Notary does not recompute that +decision as programme 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..3e3b47fc1 100644 --- a/products/notary/docs/notary-scenario-patterns.md +++ b/products/notary/docs/notary-scenario-patterns.md @@ -4,23 +4,25 @@ ```mermaid sequenceDiagram - participant App as Service application + participant Programme as Consuming programme participant Notary as Registry Notary participant Relay as Registry Relay participant Source as Registry source - App->>Notary: Evaluate claims for purpose + Programme->>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-->>Programme: Minimized evidence or credential + Programme->>Programme: Apply eligibility, workflow, and action 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 +consuming programme owns its eligibility and action rules. ## Self-attested Notary-only evaluation @@ -31,11 +33,12 @@ 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 programme eligibility. ## Delegated evaluation diff --git a/products/notary/docs/self-attestation-operator-guide.md b/products/notary/docs/self-attestation-operator-guide.md index a52346be1..aaf2e7615 100644 --- a/products/notary/docs/self-attestation-operator-guide.md +++ b/products/notary/docs/self-attestation-operator-guide.md @@ -57,12 +57,17 @@ 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 programme applies its own eligibility 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 programme eligibility, and Registry Notary +does not turn it into an eligibility decision. + Do not use it when: - The token has no trustworthy subject identifier. @@ -327,7 +332,9 @@ 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 consuming programme, not Registry Notary, owns eligibility, entitlement, + prioritization, referral, payment, and workflow 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..7c5e05773 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 programme decisions in the consuming system. ## Choose the topology @@ -15,6 +15,31 @@ 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 programme 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 + -> Consuming programme: eligibility, prioritization, workflow, and action +``` + +The three stages use different policy: + +| Owner | Policy it owns | +| --- | --- | +| Registry Relay | Source access, bounded acquisition, and source-specific adaptation | +| Registry Notary | Evidence meaning, caller authorization, disclosure, and credential issuance | +| Consuming programme | Eligibility, entitlement, prioritization, referral, payment, and case workflow | + +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 +57,49 @@ 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 a consuming programme's eligibility 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 programme eligibility 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 programmes can reuse. The consuming programme +combines those claims with its own policy after Notary returns the evidence. + +## 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 programme can reuse the statement without importing the first + programme'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 +108,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/release/conformance/integrations/README.md b/release/conformance/integrations/README.md index 71584a0bd..5d4d99392 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. 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/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/test_integration_e2_runner.py b/release/scripts/test_integration_e2_runner.py index 77061386a..51e671c85 100644 --- a/release/scripts/test_integration_e2_runner.py +++ b/release/scripts/test_integration_e2_runner.py @@ -302,6 +302,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) From 0c07fbf3601dc739ac15c376ada2f443c3fd1719 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 23 Jul 2026 11:07:08 +0700 Subject: [PATCH 02/58] feat(registryctl): define the 1.0 adopter contract Signed-off-by: Jeremi Joslin --- crates/registryctl/CHANGELOG.md | 8 + crates/registryctl/README.md | 15 +- .../schemas/registryctl.smoke.v1.schema.json | 71 +++++ crates/registryctl/src/lib.rs | 46 ++++ crates/registryctl/src/main.rs | 32 +-- crates/registryctl/tests/exit_status.rs | 57 ++++ crates/registryctl/tests/init_output.rs | 6 - docs/site/astro.config.mjs | 1 + .../content/docs/reference/registryctl.mdx | 18 +- .../content/docs/spec/rs-pr-registryctl.mdx | 243 ++++++++++++++++++ 10 files changed, 462 insertions(+), 35 deletions(-) create mode 100644 crates/registryctl/schemas/registryctl.smoke.v1.schema.json create mode 100644 crates/registryctl/tests/exit_status.rs create mode 100644 docs/site/src/content/docs/spec/rs-pr-registryctl.mdx diff --git a/crates/registryctl/CHANGELOG.md b/crates/registryctl/CHANGELOG.md index 1b77332e5..5aa49b2b8 100644 --- a/crates/registryctl/CHANGELOG.md +++ b/crates/registryctl/CHANGELOG.md @@ -14,6 +14,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). 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 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/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/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/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/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/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. From 23127c076c5eabbbfc22da447e798b9358b62af0 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 23 Jul 2026 11:59:14 +0700 Subject: [PATCH 03/58] fix(release): bind upgrade evidence to target source Signed-off-by: Jeremi Joslin --- .github/workflows/ci.yml | 6 +- release/exercises/README.md | 19 +- .../upgrade-exercise-v1.template.json | 30 +- release/scripts/check-gates-inventory.py | 4 +- release/scripts/test_check_gates_inventory.py | 8 +- .../scripts/test_validate_upgrade_exercise.py | 91 +++++- release/scripts/validate-upgrade-exercise.py | 283 +++++++++++++++--- 7 files changed, 370 insertions(+), 71 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6991b07fd..2eff28ceb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -558,10 +558,8 @@ jobs: - name: Test upgrade exercise validator run: python3 -m unittest release/scripts/test_validate_upgrade_exercise.py - - name: Validate upgrade exercise template - run: >- - python3 release/scripts/validate-upgrade-exercise.py --template - release/exercises/upgrade-exercise-v1.template.json + - name: Validate committed upgrade exercise records + run: python3 release/scripts/validate-upgrade-exercise.py --discover release/exercises - name: Stable surface compatibility env: diff --git a/release/exercises/README.md b/release/exercises/README.md index 34fb56a2b..2a66dcff5 100644 --- a/release/exercises/README.md +++ b/release/exercises/README.md @@ -28,17 +28,28 @@ 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 `:`). +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/validate-upgrade-exercise.py \ release/exercises/ + python3 release/scripts/validate-upgrade-exercise.py --require-pass \ + release/exercises/ ``` The validator accepts only a bounded schema. It records hashes and labels, not 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-gates-inventory.py b/release/scripts/check-gates-inventory.py index 3d4837d65..e24644b68 100644 --- a/release/scripts/check-gates-inventory.py +++ b/release/scripts/check-gates-inventory.py @@ -182,8 +182,8 @@ "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 record discovery", + "python3 release/scripts/validate-upgrade-exercise.py --discover release/exercises", ), ( "Base-reference compatibility input", diff --git a/release/scripts/test_check_gates_inventory.py b/release/scripts/test_check_gates_inventory.py index a050580f3..6ea5316ea 100644 --- a/release/scripts/test_check_gates_inventory.py +++ b/release/scripts/test_check_gates_inventory.py @@ -367,13 +367,13 @@ 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 --template", - "python3 release/scripts/validate-upgrade-exercise.py --skip-template", + "python3 release/scripts/validate-upgrade-exercise.py --discover release/exercises", + "python3 release/scripts/validate-upgrade-exercise.py --skip-discovery", ) 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_validate_upgrade_exercise.py b/release/scripts/test_validate_upgrade_exercise.py index 4adf52ed3..3b0512826 100644 --- a/release/scripts/test_validate_upgrade_exercise.py +++ b/release/scripts/test_validate_upgrade_exercise.py @@ -12,6 +12,8 @@ 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") def load_module(): @@ -49,8 +51,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,10 +71,24 @@ def replace(value): record["candidate_independently_verified"] = True for result in record["results"]: result["outcome"] = "passed" + 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: @@ -75,7 +97,9 @@ def test_template_is_valid_preparation_but_not_candidate_evidence(self) -> None: self.module.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.validate_record( + self.candidate(), allow_template=False, require_all_passed=True + ) def test_candidate_must_be_a_forward_version_upgrade(self) -> None: record = self.candidate() @@ -107,15 +131,17 @@ def test_authority_identifier_cannot_contain_a_url_or_subject_data(self) -> None with self.assertRaisesRegex(self.module.ExerciseError, "invalid or unsafe"): self.module.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.module.validate_record(record, allow_template=False) + with self.assertRaisesRegex(self.module.ExerciseError, "--require-pass"): + self.module.validate_record( + record, allow_template=False, require_all_passed=True + ) def test_complete_release_specific_recovery_set_is_required(self) -> None: record = self.candidate() @@ -123,10 +149,45 @@ def test_complete_release_specific_recovery_set_is_required(self) -> None: with self.assertRaisesRegex(self.module.ExerciseError, "complete release-specific"): self.module.validate_record(record, allow_template=False) - def test_candidate_must_use_committed_config_schemas(self) -> None: + def test_candidate_uses_historical_schema_not_ambient_checkout(self) -> None: + record = self.candidate() + ambient = "sha256:" + hashlib.sha256( + (ROOT / self.module.CONFIG_SCHEMAS["registry-notary"]).read_bytes() + ).hexdigest() + self.assertNotEqual(ambient, record["config_schemas"]["registry-notary"]["sha256"]) + self.module.validate_record(record, allow_template=False) + + 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.module.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.module.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.module.validate_record(record, allow_template=False) + + 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"): + 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.module.validate_record(record, allow_template=False) diff --git a/release/scripts/validate-upgrade-exercise.py b/release/scripts/validate-upgrade-exercise.py index f7c2cee85..6ed5780d4 100644 --- a/release/scripts/validate-upgrade-exercise.py +++ b/release/scripts/validate-upgrade-exercise.py @@ -7,18 +7,48 @@ 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] 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", @@ -90,11 +120,43 @@ def bounded_string( return value +def sha256_bytes(value: bytes) -> str: + return "sha256:" + hashlib.sha256(value).hexdigest() + + +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 +166,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", @@ -117,7 +186,9 @@ def version_order(value: str) -> tuple[tuple[int, int, int], bool]: return tuple(int(part) for part in core.split(".")), not bool(separator) -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,13 +202,45 @@ 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 digest.removeprefix("sha256:") != actual.removeprefix("sha256:"): 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(artifacts[name] != digest for name, digest in expected.items()): + raise ExerciseError("candidate_artifact_set does not match target release coordinates") + if artifact_set["sha256"] != canonical_sha256(artifacts): + raise ExerciseError("candidate_artifact_set.sha256 does not match its artifacts") + + def validate_topology(value: Any, *, template: bool) -> None: topology = require_object( value, @@ -179,7 +282,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 +346,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 +377,75 @@ 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"] + 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 coordinate["sha256"].removeprefix("sha256:") != sha256_bytes( + manifest_bytes + ).removeprefix("sha256:"): + 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 artifact_set[field] != release_inputs_sha256(root, commit): + raise ExerciseError(f"candidate_artifact_set.artifacts.{field} does not match exact Git object") + + +def require_pass(record: dict[str, Any]) -> None: + 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({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") + if artifacts["p_release_inputs"] != 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, +) -> None: record = require_object( data, "record", @@ -281,10 +456,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 +484,62 @@ 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) + 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") + parser.add_argument("--discover", type=Path) 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")) + validate_record(data, allow_template=data.get("record_kind") == "template") + 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, + ) except (ExerciseError, OSError, json.JSONDecodeError) as error: print(f"upgrade exercise validation failed: {error}", file=sys.stderr) return 1 From 758dc0992d383d299e4f5e5ffb333ad4be0baaf1 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 23 Jul 2026 12:02:59 +0700 Subject: [PATCH 04/58] test(release): bind conformance evidence candidates Signed-off-by: Jeremi Joslin --- release/conformance/openid/README.md | 38 ++++- release/conformance/openid/plan-map.json | 16 +- release/conformance/relay-oidc/README.md | 34 ++-- release/scripts/conformance_candidate.py | 149 ++++++++++++++++++ release/scripts/openid-conformance-runner.py | 87 ++++++++++ release/scripts/relay-oidc-smoke.py | 79 ++++------ .../scripts/test_openid_conformance_runner.py | 74 ++++++++- release/scripts/test_relay_oidc_smoke.py | 85 ++++++---- 8 files changed, 453 insertions(+), 109 deletions(-) create mode 100644 release/scripts/conformance_candidate.py diff --git a/release/conformance/openid/README.md b/release/conformance/openid/README.md index 19b010f86..3c503fbd0 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,22 @@ 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, then submit it: + +```bash +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' +``` + +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. 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 +149,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/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..c3e36ea18 100644 --- a/release/conformance/relay-oidc/README.md +++ b/release/conformance/relay-oidc/README.md @@ -6,20 +6,17 @@ 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. ## 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 @@ -75,15 +72,11 @@ Python 3.11 or later is required. Validate the checked-in topology and render a candidate-bound plan without Docker or network access: ```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 +87,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 +97,11 @@ 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. +The release-owned topology is the default. An optional Solmara adopter +exercise must add +`--topology solmara --solmara-source-ref '<40-lowercase-hex-commit>'`; +an unpinned Solmara checkout is rejected. + 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/scripts/conformance_candidate.py b/release/scripts/conformance_candidate.py new file mode 100644 index 000000000..acc388ded --- /dev/null +++ b/release/scripts/conformance_candidate.py @@ -0,0 +1,149 @@ +#!/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 re +import stat +from pathlib import Path +from typing import Any + +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_REPOSITORIES = { + "registry-notary": "ghcr.io/registrystack/registry-notary", + "registry-relay": "ghcr.io/registrystack/registry-relay", +} + + +class CandidateError(RuntimeError): + """Candidate inputs are mutable, malformed, or disagree.""" + + +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 load_candidate( + manifest_path: Path, + image_lock_path: Path, + *, + topology: str = "release-owned", + solmara_source_ref: str | None = None, +) -> dict[str, Any]: + manifest_path = require_regular_file(manifest_path, max_bytes=1024 * 1024) + image_lock_path = require_regular_file(image_lock_path, max_bytes=1024 * 1024) + if manifest_path.parent != MANIFEST_DIR.resolve(): + raise CandidateError(f"release manifest must be under {MANIFEST_DIR}") + try: + manifest_bytes = manifest_path.read_bytes() + image_lock_bytes = image_lock_path.read_bytes() + 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") + + 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:{hashlib.sha256(image_lock_bytes).hexdigest()}", + "notary_image": images["registry-notary"], + "relay_image": images["registry-relay"], + "topology": topology, + "solmara_source_ref": solmara_source_ref, + } diff --git a/release/scripts/openid-conformance-runner.py b/release/scripts/openid-conformance-runner.py index 8f412887b..13dff01d0 100755 --- a/release/scripts/openid-conformance-runner.py +++ b/release/scripts/openid-conformance-runner.py @@ -11,10 +11,12 @@ import os import shutil import ssl +import stat import subprocess import sys import time import urllib.error +import urllib.parse import urllib.request from pathlib import Path from string import Template @@ -41,6 +43,11 @@ 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 +347,75 @@ 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: + info = path.lstat() + if not stat.S_ISREG(info.st_mode) 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") + offer_uri = path.read_text(encoding="utf-8").strip() + 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 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 not in {"http", "https"} + or not endpoint.path.endswith("/credential_offer") + or endpoint.query + or endpoint.fragment + ): + raise RunnerError("suite offer endpoint must use the pinned suite origin") + url = urllib.parse.urlunsplit( + endpoint._replace(query=urllib.parse.urlencode({"credential_offer": inline})) + ) + context = ssl._create_unverified_context() + 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 output_dir_for(args: argparse.Namespace, scenario_id: str) -> Path: if args.output_dir: return Path(args.output_dir).expanduser().resolve() @@ -512,6 +588,17 @@ 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("--timeout-seconds", type=int, default=10) + offer_parser.set_defaults(func=cmd_submit_offer) + return parser.parse_args(argv) diff --git a/release/scripts/relay-oidc-smoke.py b/release/scripts/relay-oidc-smoke.py index 27c1f9b69..df3edfbb2 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,15 +162,13 @@ 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, @@ -602,9 +574,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 +584,7 @@ def safe_report( "topology", "configuration_digests", "checks", + "canary_scan", } unexpected = set(report) - allowed_keys if unexpected: @@ -647,11 +618,19 @@ 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: 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 +682,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 +692,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 +928,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 +942,14 @@ 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", + ) + parser.add_argument("--solmara-source-ref") def parse_args(argv: list[str]) -> argparse.Namespace: @@ -986,6 +972,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 +980,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_openid_conformance_runner.py b/release/scripts/test_openid_conformance_runner.py index 245b0ccfa..082bd9fe0 100755 --- a/release/scripts/test_openid_conformance_runner.py +++ b/release/scripts/test_openid_conformance_runner.py @@ -8,9 +8,10 @@ import shutil import sys import tempfile +import urllib.parse 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 @@ -79,9 +80,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 +91,71 @@ 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" + 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"}}, + } + ) + offer_uri = "openid-credential-offer://?" + urllib.parse.urlencode( + {"credential_offer": inline} + ) + 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 + with patch.object( + self.runner.urllib.request, "build_opener", return_value=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"], + ) + 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_builder_override_pins_maven_image_by_digest(self) -> None: override = self.runner.BUILDER_COMPOSE_OVERRIDE_PATH.read_text( @@ -448,7 +512,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_relay_oidc_smoke.py b/release/scripts/test_relay_oidc_smoke.py index 34dd34274..09eec20cb 100644 --- a/release/scripts/test_relay_oidc_smoke.py +++ b/release/scripts/test_relay_oidc_smoke.py @@ -9,6 +9,7 @@ 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 +19,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(): @@ -160,27 +162,9 @@ 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", - ) + candidate = {"release_id": "beta-17", "source_ref": "a" * 40} + plan = self.runner.plan_document(candidate) self.assertFalse(plan["plan_network_required"]) self.assertTrue(plan["live_run_requires_docker"]) @@ -188,16 +172,53 @@ def test_plan_is_offline_and_does_not_claim_live_evidence(self) -> None: 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_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-16.yaml" + ) + 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": "b" * 40, + "platform": "linux/amd64", + "images": { + "registry-notary": ( + "ghcr.io/registrystack/registry-notary@sha256:" + "c" * 64 + ), + "registry-relay": ( + "ghcr.io/registrystack/registry-relay@sha256:" + "d" * 64 + ), + }, + } + image_lock.write_text(json.dumps(lock), encoding="utf-8") + args = Namespace( + release_manifest=manifest, + image_lock=image_lock, + topology="release-owned", + solmara_source_ref=None, + ) + candidate = self.runner.candidate_from_args(args) + self.assertEqual("beta-16", candidate["release_id"]) + self.assertEqual(lock["images"]["registry-relay"], candidate["relay_image"]) + + 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"] = "0e76f5ea61f78bbc15d91fcb6e9dfcaa956c3df8" + 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) def test_native_zitadel_role_claim_drives_scope_profile(self) -> None: token = fake_token(role_claim={"registry-smoke-reader": {"org-1": "active"}}) @@ -323,7 +344,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 +360,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) From 2e789cd1bd17ba5df1657fc7f8625e2114bc8dca Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 23 Jul 2026 12:05:38 +0700 Subject: [PATCH 05/58] fix(release): harden offer file reads Signed-off-by: Jeremi Joslin --- release/scripts/openid-conformance-runner.py | 36 +++++++++-- .../scripts/test_openid_conformance_runner.py | 64 +++++++++++++++---- 2 files changed, 83 insertions(+), 17 deletions(-) diff --git a/release/scripts/openid-conformance-runner.py b/release/scripts/openid-conformance-runner.py index 13dff01d0..09b2bacea 100755 --- a/release/scripts/openid-conformance-runner.py +++ b/release/scripts/openid-conformance-runner.py @@ -348,12 +348,36 @@ def wait_for_suite(base_url: str, timeout_seconds: int) -> None: def read_offer(path: Path, issuer_url: str) -> str: - info = path.lstat() - if not stat.S_ISREG(info.st_mode) 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") - offer_uri = path.read_text(encoding="utf-8").strip() + 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) diff --git a/release/scripts/test_openid_conformance_runner.py b/release/scripts/test_openid_conformance_runner.py index 082bd9fe0..35a7ab47d 100755 --- a/release/scripts/test_openid_conformance_runner.py +++ b/release/scripts/test_openid_conformance_runner.py @@ -36,6 +36,21 @@ 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})) @@ -102,17 +117,7 @@ def test_submit_offer_forwards_only_the_real_notary_preauthorized_offer( self, ) -> None: issuer = "https://issuer.example.test" - 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"}}, - } - ) - offer_uri = "openid-credential-offer://?" + urllib.parse.urlencode( - {"credential_offer": inline} - ) + 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") @@ -157,6 +162,43 @@ def test_submit_offer_forwards_only_the_real_notary_preauthorized_offer( self.runner.cmd_submit_offer(args) self.assertNotIn("owner-only-code", str(caught.exception)) + 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( encoding="utf-8" From 3462a3d57891cbbeb34cba5799836d4226e7a4a9 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 23 Jul 2026 12:16:14 +0700 Subject: [PATCH 06/58] fix(release): verify candidate tag lineage Signed-off-by: Jeremi Joslin --- release/scripts/conformance_candidate.py | 46 +++++++++++++++++ release/scripts/test_relay_oidc_smoke.py | 64 +++++++++++++++++++++--- 2 files changed, 103 insertions(+), 7 deletions(-) diff --git a/release/scripts/conformance_candidate.py b/release/scripts/conformance_candidate.py index acc388ded..e3fa84170 100644 --- a/release/scripts/conformance_candidate.py +++ b/release/scripts/conformance_candidate.py @@ -8,6 +8,7 @@ import json import re import stat +import subprocess from pathlib import Path from typing import Any @@ -28,6 +29,50 @@ class CandidateError(RuntimeError): """Candidate inputs are mutable, malformed, or disagree.""" +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 object_size != len(manifest_bytes): + raise CandidateError("candidate Git binding could not be verified") + if git_output(["show", object_name], object_size) != 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: @@ -123,6 +168,7 @@ def load_candidate( is None ): raise CandidateError(f"{component} is not pinned to its exact digest") + verify_git_binding(stack, lock, manifest_path, manifest_bytes) if topology == "solmara": if ( diff --git a/release/scripts/test_relay_oidc_smoke.py b/release/scripts/test_relay_oidc_smoke.py index 09eec20cb..b5190c902 100644 --- a/release/scripts/test_relay_oidc_smoke.py +++ b/release/scripts/test_relay_oidc_smoke.py @@ -178,14 +178,14 @@ def test_candidate_binding_rejects_manifest_lock_and_image_mismatches(self) -> N candidate_module = sys.modules["conformance_candidate"] with tempfile.TemporaryDirectory() as tmp: manifest = ( - self.runner.REPO_ROOT / "release/manifests/registry-stack-beta-16.yaml" + self.runner.REPO_ROOT / "release/manifests/registry-stack-beta-15.yaml" ) - image_lock = Path(tmp) / "registryctl-v0.12.2-image-lock.json" + image_lock = Path(tmp) / "registryctl-v0.12.1-image-lock.json" lock = { "schema_version": "registryctl.release_image_lock.v1", - "release_tag": "v0.12.2", - "manifest_source_ref": "0e76f5ea61f78bbc15d91fcb6e9dfcaa956c3df8", - "tag_target": "b" * 40, + "release_tag": "v0.12.1", + "manifest_source_ref": "a6f409259158f44c9fdbe99242ec0f9ac10d9373", + "tag_target": "567fb93704d25855238e11fd43c7cb9bf8a2f28e", "platform": "linux/amd64", "images": { "registry-notary": ( @@ -204,7 +204,7 @@ def test_candidate_binding_rejects_manifest_lock_and_image_mismatches(self) -> N solmara_source_ref=None, ) candidate = self.runner.candidate_from_args(args) - self.assertEqual("beta-16", candidate["release_id"]) + self.assertEqual("beta-15", candidate["release_id"]) self.assertEqual(lock["images"]["registry-relay"], candidate["relay_image"]) lock["manifest_source_ref"] = "e" * 40 @@ -214,12 +214,62 @@ def test_candidate_binding_rejects_manifest_lock_and_image_mismatches(self) -> N ): self.runner.candidate_from_args(args) - lock["manifest_source_ref"] = "0e76f5ea61f78bbc15d91fcb6e9dfcaa956c3df8" + 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_promoted_manifest_not_present_at_tag( + self, + ) -> None: + candidate_module = sys.modules["conformance_candidate"] + with tempfile.TemporaryDirectory() as tmp: + image_lock = Path(tmp) / "registryctl-v0.12.2-image-lock.json" + image_lock.write_text( + json.dumps( + { + "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 + ), + }, + } + ), + encoding="utf-8", + ) + args = Namespace( + release_manifest=( + self.runner.REPO_ROOT + / "release/manifests/registry-stack-beta-16.yaml" + ), + image_lock=image_lock, + topology="release-owned", + solmara_source_ref=None, + ) + with self.assertRaisesRegex(candidate_module.CandidateError, "Git binding"): + self.runner.candidate_from_args(args) + def test_native_zitadel_role_claim_drives_scope_profile(self) -> None: token = fake_token(role_claim={"registry-smoke-reader": {"org-1": "active"}}) profile = self.runner.inspect_token(token, self.topology()) From 428210b614ee06d2876bc66f9d99f6077c076143 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 23 Jul 2026 11:19:39 +0700 Subject: [PATCH 07/58] fix(platform): harden deployment waiver metadata Signed-off-by: Jeremi Joslin --- .../src/config/tests/root.rs | 55 ++- crates/registry-notary-core/src/deployment.rs | 150 +++++- crates/registry-notary-server/src/posture.rs | 42 +- .../src/standalone/assembly.rs | 1 + .../src/standalone/deployment.rs | 30 ++ .../src/standalone/runtime.rs | 3 +- .../src/standalone/tests/deployment_gates.rs | 64 ++- crates/registry-notary/tests/config_schema.rs | 39 ++ .../fixtures/posture/default-allowlist.json | 6 +- .../posture/restricted-posture.valid.json | 6 +- .../registry.ops.posture.v1.schema.json | 26 +- crates/registry-platform-ops/src/lib.rs | 427 +++++++++++++++++- .../tests/posture_profile_gates_contract.rs | 96 +++- crates/registry-relay/CHANGELOG.md | 7 + crates/registry-relay/docs/configuration.md | 17 +- crates/registry-relay/src/api/admin.rs | 67 +-- crates/registry-relay/src/config/mod.rs | 40 +- crates/registry-relay/src/config/validate.rs | 70 +-- crates/registry-relay/src/deployment/mod.rs | 48 +- crates/registry-relay/tests/config_schema.rs | 39 ++ .../tests/deployment_profile_gates.rs | 105 ++++- .../docs/security/hardening-checklist.mdx | 7 +- docs/site/src/content/docs/security/index.mdx | 7 +- .../src/content/docs/spec/rs-op-posture.mdx | 27 +- products/notary/CHANGELOG.md | 8 + .../notary/docs/operator-config-reference.md | 25 +- products/platform/CHANGELOG.md | 6 + schemas/registry-notary.config.schema.json | 14 +- schemas/registry-relay.config.schema.json | 16 +- 29 files changed, 1268 insertions(+), 180 deletions(-) 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..e2ba09c04 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(length(min = 1, max = 128), pattern(r"^(?!.*\.\.)[A-Za-z0-9._:-]+$"))] + pub reference: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(with = "String", length(min = 1, max = 256))] + 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,76 @@ 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-----", + ] { + 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-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/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/tests/config_schema.rs b/crates/registry-notary/tests/config_schema.rs index 8643a363d..899c1bcc7 100644 --- a/crates/registry-notary/tests/config_schema.rs +++ b/crates/registry-notary/tests/config_schema.rs @@ -67,6 +67,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) @@ -297,6 +308,34 @@ 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 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..860ade953 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 @@ -427,13 +427,20 @@ "type": "object", "additionalProperties": false, "required": [ - "reason", + "reference", "expires" ], "properties": { - "reason": { + "reference": { "type": "string", - "minLength": 1 + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!.*\\.\\.)[A-Za-z0-9._:-]+$" + }, + "summary": { + "type": "string", + "minLength": 1, + "maxLength": 256 }, "expires": { "type": "string", @@ -447,16 +454,23 @@ "additionalProperties": false, "required": [ "finding", - "reason", + "reference", "expires" ], "properties": { "finding": { "$ref": "#/$defs/finding_id" }, - "reason": { + "reference": { "type": "string", - "minLength": 1 + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!.*\\.\\.)[A-Za-z0-9._:-]+$" + }, + "summary": { + "type": "string", + "minLength": 1, + "maxLength": 256 }, "expires": { "type": "string", diff --git a/crates/registry-platform-ops/src/lib.rs b/crates/registry-platform-ops/src/lib.rs index 26c90c749..f5ba379f4 100644 --- a/crates/registry-platform-ops/src/lib.rs +++ b/crates/registry-platform-ops/src/lib.rs @@ -126,11 +126,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 +171,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 +1106,216 @@ 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, + SummaryLength, + SummaryNotCanonical, + SummaryControlCharacter, + SummaryCredentialLiteral, +} + +impl DeploymentWaiverMetadataError { + pub const fn field(self) -> &'static str { + match self { + Self::ReferenceLength | Self::ReferenceNotCanonical | Self::ReferenceInvalidSyntax => { + "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::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); + } + + 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(()) +} + +fn contains_high_confidence_credential_literal(summary: &str) -> bool { + ["Bearer", "Basic"] + .iter() + .any(|scheme| contains_authorization_value_suffix(summary, scheme)) + || summary.split("-----BEGIN ").skip(1).any(|suffix| { + suffix + .split_once("-----") + .is_some_and(|(label, _)| label == "PRIVATE KEY" || label.ends_with(" PRIVATE KEY")) + }) +} + +fn contains_authorization_value_suffix(summary: &str, scheme: &str) -> bool { + let mut words = summary.split_whitespace().rev(); + let Some(credential) = words.next() else { + return false; + }; + let Some(candidate_scheme) = words.next() else { + return false; + }; + candidate_scheme.eq_ignore_ascii_case(scheme) + && !credential.is_empty() + && !credential.chars().any(char::is_whitespace) +} + #[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] pub struct BreakGlassApproval { pub approved_by: String, @@ -3464,6 +3736,157 @@ 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"); + 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) + ); + } + + #[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, + ), + ( + "accidentally pasted -----BEGIN PRIVATE KEY-----", + DeploymentWaiverMetadataError::SummaryCredentialLiteral, + ), + ( + "accidentally pasted -----BEGIN OPENSSH PRIVATE KEY-----", + DeploymentWaiverMetadataError::SummaryCredentialLiteral, + ), + ( + "accidentally pasted -----BEGIN ED25519 PRIVATE KEY-----", + 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", + ] { + 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..f6f0b0f8b 100644 --- a/crates/registry-platform-ops/tests/posture_profile_gates_contract.rs +++ b/crates/registry-platform-ops/tests/posture_profile_gates_contract.rs @@ -57,7 +57,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 +66,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" } ] @@ -143,7 +145,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 +155,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 +165,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 +303,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 +323,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..fe10ead06 100644 --- a/crates/registry-relay/CHANGELOG.md +++ b/crates/registry-relay/CHANGELOG.md @@ -2,6 +2,13 @@ ## Unreleased +- 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/configuration.md b/crates/registry-relay/docs/configuration.md index eaf0ffcee..3a4f885bb 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,21 @@ 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. +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 `..`. 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. 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. +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 +1340,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/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/config/mod.rs b/crates/registry-relay/src/config/mod.rs index 11d7d5c7f..008713e46 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(length(min = 1, max = 128), pattern(r"^(?!.*\.\.)[A-Za-z0-9._:-]+$"))] + pub reference: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(with = "String", length(min = 1, max = 256))] + 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/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/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/tests/config_schema.rs b/crates/registry-relay/tests/config_schema.rs index c55830e65..4bd09caf0 100644 --- a/crates/registry-relay/tests/config_schema.rs +++ b/crates/registry-relay/tests/config_schema.rs @@ -72,6 +72,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!( + 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")) } @@ -252,6 +263,34 @@ 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 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..bce8ee192 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,105 @@ 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-----", + &"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 +224,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 +316,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 +373,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 +397,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/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..dae9162a5 100644 --- a/docs/site/src/content/docs/spec/rs-op-posture.mdx +++ b/docs/site/src/content/docs/spec/rs-op-posture.mdx @@ -42,6 +42,7 @@ 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. | ## 1. Scope and surface @@ -116,6 +117,23 @@ 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 `..`. 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. 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 +165,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 +247,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 diff --git a/products/notary/CHANGELOG.md b/products/notary/CHANGELOG.md index dcafbe3b4..1e88ca66e 100644 --- a/products/notary/CHANGELOG.md +++ b/products/notary/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- 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/docs/operator-config-reference.md b/products/notary/docs/operator-config-reference.md index 14b113f70..32fd542bb 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,28 @@ 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 `..`. 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. + +```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/platform/CHANGELOG.md b/products/platform/CHANGELOG.md index eb401cea6..169f9d372 100644 --- a/products/platform/CHANGELOG.md +++ b/products/platform/CHANGELOG.md @@ -2,6 +2,12 @@ ## 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. + ## v0.12.2 - 2026-07-20 - No user-visible Registry Platform changes. This release fixes forward from diff --git a/schemas/registry-notary.config.schema.json b/schemas/registry-notary.config.schema.json index e7705e411..4503a6ae3 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,13 +649,21 @@ "finding": { "type": "string" }, - "reason": { + "reference": { + "maxLength": 128, + "minLength": 1, + "pattern": "^(?!.*\\.\\.)[A-Za-z0-9._:-]+$", + "type": "string" + }, + "summary": { + "maxLength": 256, + "minLength": 1, "type": "string" } }, "required": [ "finding", - "reason", + "reference", "expires" ], "type": "object" diff --git a/schemas/registry-relay.config.schema.json b/schemas/registry-relay.config.schema.json index f0ad5514b..815344c7a 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,13 +1515,21 @@ "finding": { "type": "string" }, - "reason": { + "reference": { + "maxLength": 128, + "minLength": 1, + "pattern": "^(?!.*\\.\\.)[A-Za-z0-9._:-]+$", + "type": "string" + }, + "summary": { + "maxLength": 256, + "minLength": 1, "type": "string" } }, "required": [ "finding", - "reason", + "reference", "expires" ], "type": "object" From 1ffaf39c04b9049306462ed1997dad01d25fae3c Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 23 Jul 2026 11:37:53 +0700 Subject: [PATCH 08/58] docs(release): add external pilot report template Signed-off-by: Jeremi Joslin --- release/conformance/integrations/README.md | 6 ++ .../integrations/pilot-report.template.md | 79 +++++++++++++++++++ release/scripts/test_integration_e2_runner.py | 34 ++++++++ 3 files changed, 119 insertions(+) create mode 100644 release/conformance/integrations/pilot-report.template.md diff --git a/release/conformance/integrations/README.md b/release/conformance/integrations/README.md index 71584a0bd..344a97339 100644 --- a/release/conformance/integrations/README.md +++ b/release/conformance/integrations/README.md @@ -163,6 +163,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..3baf9eb4d --- /dev/null +++ b/release/conformance/integrations/pilot-report.template.md @@ -0,0 +1,79 @@ +# 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 schema-valid public result] +- Frozen Registry Stack candidate: [link to the published release] +- Immutable Solmara release and release-pin evidence: [link] +- 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] +- Overall outcome: [passed, failed, or incomplete] + +Issue closure still requires a frozen published candidate, an immutable Solmara +release pinned to that candidate, an independent operator, and an +owner-approved source. 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. Reiterate any unexercised area +that affects the conclusion.] diff --git a/release/scripts/test_integration_e2_runner.py b/release/scripts/test_integration_e2_runner.py index 77061386a..c6ea04459 100644 --- a/release/scripts/test_integration_e2_runner.py +++ b/release/scripts/test_integration_e2_runner.py @@ -268,6 +268,40 @@ 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, + ) + for text in ( + "Sanitized run result:", + "Plans, dry runs", + "One completed pilot is not proof of broad production readiness.", + "Frozen Registry Stack candidate:", + "Immutable Solmara release", + "Independent operator:", + "Owner-approved non-production source:", + "### 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 From 5d9b8bacb3385ef3deba47b44d763bdac4cd71ef Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 23 Jul 2026 11:43:57 +0700 Subject: [PATCH 09/58] docs(registryctl): add OpenSPP owner holdout runbook Signed-off-by: Jeremi Joslin --- .../project-authoring/openspp-exact/README.md | 212 ++++++++++++++++++ 1 file changed, 212 insertions(+) create mode 100644 crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md 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..60c90e2ad --- /dev/null +++ b/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md @@ -0,0 +1,212 @@ +# 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. +The following request shape matches the committed synthetic project, so replace +the identifier scheme, value, purpose, and claim ids when the authored project +changes: + +```json +{ + "target": { + "type": "Person", + "identifiers": [ + { + "scheme": "openspp_individual_id", + "value": "IND-AB12CD34" + } + ] + }, + "claims": [ + "social-registry-record-exists", + "social-registry-active", + "programme-code", + "household-reference" + ], + "format": "application/vnd.registry-notary.claim-result+json", + "purpose": "social-programme-verification" +} +``` + +The expected-result file must contain only a `claims` object. +Its keys must exactly match the request's claim ids. +Each claim value must be a non-empty object containing only the exact `value`, +`satisfied`, or `disclosure` fields that the owner intends to verify. +This example reflects only the committed synthetic fixture and must be replaced +with reviewed expectations for the owner-approved record: + +```json +{ + "claims": { + "social-registry-record-exists": { + "satisfied": true, + "disclosure": "predicate" + }, + "social-registry-active": { + "satisfied": true, + "disclosure": "predicate" + }, + "programme-code": { + "value": "SUPPORT", + "disclosure": "value" + }, + "household-reference": { + "disclosure": "redacted" + } + } +} +``` + +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 a production environment. +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. From 50dc91a6ab7a8f13c0d8f4feba3df230a2cfd814 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 23 Jul 2026 11:46:45 +0700 Subject: [PATCH 10/58] docs: clarify Solmara reader gate Signed-off-by: Jeremi Joslin --- .../content/docs/tutorials/first-run-with-solmara-lab.mdx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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..b33e3dfcc 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,10 @@ 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. Solmara Lab does not yet have an immutable release. The +final 1.0 release-candidate reader run remains gated in +[GH#198](https://github.com/registrystack/registry-stack/issues/198) on a tagged Registry Stack +candidate and an atomically pinned Solmara Lab release. ## Prerequisites From 7c6477bac66ea0d1bdd701b9325544b173b56e70 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 23 Jul 2026 11:19:30 +0700 Subject: [PATCH 11/58] fix(relay): remove inert consent provenance Signed-off-by: Jeremi Joslin --- .../src/relay_client.rs | 39 +-- .../src/relay_client/tests.rs | 38 ++- .../tests/standalone_http/support.rs | 8 - crates/registry-relay/CHANGELOG.md | 14 + crates/registry-relay/docs/api.md | 2 +- .../openapi/oasdiff-err-ignore.txt | 4 + .../openapi/registry-relay.openapi.json | 300 +----------------- crates/registry-relay/src/api/openapi.rs | 24 +- .../src/consultation/response.rs | 92 +++--- .../src/state_plane/migration.rs | 49 +-- .../src/state_plane/postgres_tests.rs | 4 +- products/notary/CHANGELOG.md | 9 + 12 files changed, 118 insertions(+), 465 deletions(-) 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/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-relay/CHANGELOG.md b/crates/registry-relay/CHANGELOG.md index 1f4172efd..7be525aac 100644 --- a/crates/registry-relay/CHANGELOG.md +++ b/crates/registry-relay/CHANGELOG.md @@ -2,6 +2,20 @@ ## 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. + ## 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/openapi/oasdiff-err-ignore.txt b/crates/registry-relay/openapi/oasdiff-err-ignore.txt index b94ab98ef..6c7a825c1 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/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/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/state_plane/migration.rs b/crates/registry-relay/src/state_plane/migration.rs index d3cbf9db0..b094ece97 100644 --- a/crates/registry-relay/src/state_plane/migration.rs +++ b/crates/registry-relay/src/state_plane/migration.rs @@ -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), 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/products/notary/CHANGELOG.md b/products/notary/CHANGELOG.md index dcafbe3b4..6fa70de79 100644 --- a/products/notary/CHANGELOG.md +++ b/products/notary/CHANGELOG.md @@ -7,6 +7,15 @@ 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. + ## [0.12.2] - 2026-07-20 - No new Notary product features or public API contract changes. This release From 72046facc3cbb9f5674b3279fd72e226f186f35b Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 23 Jul 2026 15:54:02 +0700 Subject: [PATCH 12/58] test(release): isolate historical schema binding Signed-off-by: Jeremi Joslin --- .../scripts/test_validate_upgrade_exercise.py | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/release/scripts/test_validate_upgrade_exercise.py b/release/scripts/test_validate_upgrade_exercise.py index 3b0512826..8089f4117 100644 --- a/release/scripts/test_validate_upgrade_exercise.py +++ b/release/scripts/test_validate_upgrade_exercise.py @@ -7,6 +7,7 @@ import sys import unittest from pathlib import Path +from unittest import mock ROOT = Path(__file__).resolve().parents[2] @@ -151,11 +152,32 @@ def test_complete_release_specific_recovery_set_is_required(self) -> None: 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 / self.module.CONFIG_SCHEMAS["registry-notary"]).read_bytes() + (ROOT / notary_schema).read_bytes() ).hexdigest() self.assertNotEqual(ambient, record["config_schemas"]["registry-notary"]["sha256"]) - self.module.validate_record(record, allow_template=False) + 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() From 04665ba14e3350e198de799a35240a489af3726c Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 23 Jul 2026 21:36:56 +0700 Subject: [PATCH 13/58] test(platform): keep waiver fixtures scanner-safe Signed-off-by: Jeremi Joslin --- crates/registry-platform-ops/src/lib.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/crates/registry-platform-ops/src/lib.rs b/crates/registry-platform-ops/src/lib.rs index f5ba379f4..24c32d6f3 100644 --- a/crates/registry-platform-ops/src/lib.rs +++ b/crates/registry-platform-ops/src/lib.rs @@ -3812,15 +3812,21 @@ mod tests { DeploymentWaiverMetadataError::SummaryCredentialLiteral, ), ( - "accidentally pasted -----BEGIN PRIVATE KEY-----", + concat!("accidentally pasted -----BEGIN PRIVATE ", "KEY-----"), DeploymentWaiverMetadataError::SummaryCredentialLiteral, ), ( - "accidentally pasted -----BEGIN OPENSSH PRIVATE KEY-----", + concat!( + "accidentally pasted -----BEGIN OPENSSH PRIVATE ", + "KEY-----" + ), DeploymentWaiverMetadataError::SummaryCredentialLiteral, ), ( - "accidentally pasted -----BEGIN ED25519 PRIVATE KEY-----", + concat!( + "accidentally pasted -----BEGIN ED25519 PRIVATE ", + "KEY-----" + ), DeploymentWaiverMetadataError::SummaryCredentialLiteral, ), ] { From 554538b94c31b81d3507c0a4dab85c79439b720d Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 23 Jul 2026 21:36:56 +0700 Subject: [PATCH 14/58] fix(relay): refresh consent-free state-plane attestation Signed-off-by: Jeremi Joslin --- .../openapi/oasdiff-err-ignore.txt | 2 +- .../src/state_plane/migration.rs | 36 +++++++++---------- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/crates/registry-relay/openapi/oasdiff-err-ignore.txt b/crates/registry-relay/openapi/oasdiff-err-ignore.txt index 6c7a825c1..ee56082af 100644 --- a/crates/registry-relay/openapi/oasdiff-err-ignore.txt +++ b/crates/registry-relay/openapi/oasdiff-err-ignore.txt @@ -24,4 +24,4 @@ GET /v1/datasets/{dataset_id}/measures/{item_id} added the new path request para # 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' +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/src/state_plane/migration.rs b/crates/registry-relay/src/state_plane/migration.rs index b094ece97..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$; @@ -8897,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", From 1b66b6f991d782a3e52e0dc210a5df6d54d1f892 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 23 Jul 2026 21:46:11 +0700 Subject: [PATCH 15/58] fix(release): close candidate evidence gaps Signed-off-by: Jeremi Joslin --- .../scripts/test_validate_upgrade_exercise.py | 77 +++++++++++++++++++ release/scripts/validate-upgrade-exercise.py | 19 ++++- 2 files changed, 95 insertions(+), 1 deletion(-) diff --git a/release/scripts/test_validate_upgrade_exercise.py b/release/scripts/test_validate_upgrade_exercise.py index 8089f4117..b308154b6 100644 --- a/release/scripts/test_validate_upgrade_exercise.py +++ b/release/scripts/test_validate_upgrade_exercise.py @@ -3,8 +3,10 @@ import copy import hashlib import importlib.util +import io import json import sys +import tempfile import unittest from pathlib import Path from unittest import mock @@ -102,6 +104,35 @@ def test_complete_candidate_record_is_valid(self) -> None: self.candidate(), 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.module.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"] @@ -144,6 +175,52 @@ def test_failed_and_not_run_results_are_recordable_but_fail_promotion(self) -> N record, allow_template=False, require_all_passed=True ) + def test_discovery_requires_candidate_passes_but_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), "--require-pass"], + ), 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), "--require-pass"], + ), 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.module.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() diff --git a/release/scripts/validate-upgrade-exercise.py b/release/scripts/validate-upgrade-exercise.py index 6ed5780d4..dc0f58d0b 100644 --- a/release/scripts/validate-upgrade-exercise.py +++ b/release/scripts/validate-upgrade-exercise.py @@ -381,6 +381,15 @@ 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}}"], @@ -435,6 +444,9 @@ def require_pass(record: dict[str, Any]) -> None: for kind in ("binaries", "image_inputs"): if len({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 artifacts[f"p_{product}_layouts"] != artifacts[f"t_{product}_layouts"]: + raise ExerciseError(f"--require-pass rejects P/T {product} OCI layout drift") if artifacts["p_release_inputs"] != artifacts["t_release_inputs"]: raise ExerciseError("--require-pass rejects P/T release-input drift") @@ -529,7 +541,12 @@ def main() -> int: raise ExerciseError("--discover found no JSON records") for path in records: data = json.loads(path.read_text(encoding="utf-8")) - validate_record(data, allow_template=data.get("record_kind") == "template") + template = data.get("record_kind") == "template" + validate_record( + data, + allow_template=template, + require_all_passed=args.require_pass and not template, + ) print(f"upgrade exercise discovery passed: {len(records)} record(s)") return 0 if args.record is None: From c943f8b9925732308c0ea5c49df323ece21817f9 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 23 Jul 2026 21:50:57 +0700 Subject: [PATCH 16/58] fix(platform): scan waiver summaries for auth values Signed-off-by: Jeremi Joslin --- crates/registry-platform-ops/src/lib.rs | 62 +++++++++++++++++++++---- 1 file changed, 52 insertions(+), 10 deletions(-) diff --git a/crates/registry-platform-ops/src/lib.rs b/crates/registry-platform-ops/src/lib.rs index 24c32d6f3..8ff4881a5 100644 --- a/crates/registry-platform-ops/src/lib.rs +++ b/crates/registry-platform-ops/src/lib.rs @@ -1295,7 +1295,7 @@ pub fn validate_deployment_waiver_metadata( fn contains_high_confidence_credential_literal(summary: &str) -> bool { ["Bearer", "Basic"] .iter() - .any(|scheme| contains_authorization_value_suffix(summary, scheme)) + .any(|scheme| contains_authorization_value(summary, scheme)) || summary.split("-----BEGIN ").skip(1).any(|suffix| { suffix .split_once("-----") @@ -1303,17 +1303,42 @@ fn contains_high_confidence_credential_literal(summary: &str) -> bool { }) } -fn contains_authorization_value_suffix(summary: &str, scheme: &str) -> bool { - let mut words = summary.split_whitespace().rev(); - let Some(credential) = words.next() else { +fn contains_authorization_value(summary: &str, scheme: &str) -> bool { + let mut words = summary.split_whitespace(); + let Some(mut candidate_scheme) = words.next() else { return false; }; - let Some(candidate_scheme) = words.next() else { - return false; - }; - candidate_scheme.eq_ignore_ascii_case(scheme) - && !credential.is_empty() - && !credential.chars().any(char::is_whitespace) + + for candidate_value in words { + if candidate_scheme.eq_ignore_ascii_case(scheme) + && is_credential_like_authorization_value(candidate_value) + { + return true; + } + candidate_scheme = candidate_value; + } + false +} + +fn is_credential_like_authorization_value(candidate: &str) -> bool { + let prose_word = candidate.trim_matches(|character: char| character.is_ascii_punctuation()); + !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)) } #[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] @@ -3811,6 +3836,18 @@ mod tests { "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, + ), ( concat!("accidentally pasted -----BEGIN PRIVATE ", "KEY-----"), DeploymentWaiverMetadataError::SummaryCredentialLiteral, @@ -3862,6 +3899,11 @@ mod tests { "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", ] { validate_deployment_waiver_metadata("OPS-42", Some(summary)) .unwrap_or_else(|error| panic!("ordinary summary rejected: {summary:?}: {error}")); From 36459f207160276f2cbfed2b1012ed32810d5f70 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 23 Jul 2026 21:51:13 +0700 Subject: [PATCH 17/58] fix: correct adopter exercise evidence semantics Signed-off-by: Jeremi Joslin --- .../src/project_authoring/commands.rs | 24 +++++++--- .../src/project_authoring/tests.rs | 47 +++++++++++++++++++ .../project-authoring/openspp-exact/README.md | 4 ++ .../integrations/pilot-report.template.md | 8 ++-- release/scripts/test_integration_e2_runner.py | 2 +- 5 files changed, 72 insertions(+), 13 deletions(-) diff --git a/crates/registryctl/src/project_authoring/commands.rs b/crates/registryctl/src/project_authoring/commands.rs index 149557e5b..e819d01a6 100644 --- a/crates/registryctl/src/project_authoring/commands.rs +++ b/crates/registryctl/src/project_authoring/commands.rs @@ -606,10 +606,7 @@ fn execute_governed_live_test(loaded: &LoadedRegistryProject) -> Result Result 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<()> { diff --git a/crates/registryctl/src/project_authoring/tests.rs b/crates/registryctl/src/project_authoring/tests.rs index 750ec2394..a5982b1fd 100644 --- a/crates/registryctl/src/project_authoring/tests.rs +++ b/crates/registryctl/src/project_authoring/tests.rs @@ -683,6 +683,53 @@ outputs: ); } + #[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 claims = vec!["record-exists".to_string()]; + let expected = json!({ + "claims": { + "record-exists": { + "satisfied": false, + "disclosure": "predicate", + }, + }, + }); + let response = json!({ + "results": [{ + "claim_id": "record-exists", + "satisfied": false, + "disclosure": "predicate", + "provenance": { "used": { "relay_consultation_count": 1 } }, + }], + }); + let returned_claims = + validate_live_response(&response, &claims, &expected).expect("no-match result passes"); + let report = governed_live_fixture_report(returned_claims); + + assert_eq!(report.claims, 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!( diff --git a/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md b/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md index 60c90e2ad..1de395fd8 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md +++ b/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md @@ -107,6 +107,10 @@ changes: } ``` +`registryctl` sends +`Accept: application/vnd.registry-notary.claim-result+json`, matching the +request's `format`, and validates that claim-result envelope. + The expected-result file must contain only a `claims` object. Its keys must exactly match the request's claim ids. Each claim value must be a non-empty object containing only the exact `value`, diff --git a/release/conformance/integrations/pilot-report.template.md b/release/conformance/integrations/pilot-report.template.md index 3baf9eb4d..aa4a0f062 100644 --- a/release/conformance/integrations/pilot-report.template.md +++ b/release/conformance/integrations/pilot-report.template.md @@ -13,7 +13,6 @@ evidence. One completed pilot is not proof of broad production readiness. - Sanitized run result: [link to the schema-valid public result] - Frozen Registry Stack candidate: [link to the published release] -- Immutable Solmara release and release-pin evidence: [link] - Independent operator: [confirmed or not confirmed; do not identify the operator] - Owner-approved non-production source: [confirmed or not confirmed; do not @@ -25,10 +24,9 @@ evidence. One completed pilot is not proof of broad production readiness. not close] - Overall outcome: [passed, failed, or incomplete] -Issue closure still requires a frozen published candidate, an immutable Solmara -release pinned to that candidate, an independent operator, and an -owner-approved source. A plan or maintainer-run substitute cannot satisfy any -of these requirements. +Issue closure still requires a frozen published candidate, an independent +operator, and an owner-approved source. A plan or maintainer-run substitute +cannot satisfy any of these requirements. ## What the pilot did and did not prove diff --git a/release/scripts/test_integration_e2_runner.py b/release/scripts/test_integration_e2_runner.py index c6ea04459..422fbda27 100644 --- a/release/scripts/test_integration_e2_runner.py +++ b/release/scripts/test_integration_e2_runner.py @@ -281,12 +281,12 @@ def test_pilot_report_template_preserves_the_public_contract(self) -> None: "links to restricted evidence.", normalized_template, ) + self.assertNotIn("Solmara", template) for text in ( "Sanitized run result:", "Plans, dry runs", "One completed pilot is not proof of broad production readiness.", "Frozen Registry Stack candidate:", - "Immutable Solmara release", "Independent operator:", "Owner-approved non-production source:", "### Blocking findings", From cd293a07c9e418a17d1b3ebf7ab51e5ef12cabf3 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 23 Jul 2026 21:52:50 +0700 Subject: [PATCH 18/58] fix(release): enforce discovered candidate promotion Signed-off-by: Jeremi Joslin --- release/scripts/test_validate_upgrade_exercise.py | 6 +++--- release/scripts/validate-upgrade-exercise.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/release/scripts/test_validate_upgrade_exercise.py b/release/scripts/test_validate_upgrade_exercise.py index b308154b6..4031ed277 100644 --- a/release/scripts/test_validate_upgrade_exercise.py +++ b/release/scripts/test_validate_upgrade_exercise.py @@ -175,7 +175,7 @@ def test_failed_and_not_run_results_are_recordable_but_fail_promotion(self) -> N record, allow_template=False, require_all_passed=True ) - def test_discovery_requires_candidate_passes_but_preserves_templates(self) -> None: + def test_discovery_requires_candidate_passes_and_preserves_templates(self) -> None: with tempfile.TemporaryDirectory() as temporary: records = Path(temporary) (records / "template.json").write_text( @@ -184,7 +184,7 @@ def test_discovery_requires_candidate_passes_but_preserves_templates(self) -> No with mock.patch.object( sys, "argv", - [str(SCRIPT), "--discover", str(records), "--require-pass"], + [str(SCRIPT), "--discover", str(records)], ), mock.patch("sys.stdout", new=io.StringIO()): self.assertEqual(self.module.main(), 0) @@ -197,7 +197,7 @@ def test_discovery_requires_candidate_passes_but_preserves_templates(self) -> No with mock.patch.object( sys, "argv", - [str(SCRIPT), "--discover", str(records), "--require-pass"], + [str(SCRIPT), "--discover", str(records)], ), mock.patch("sys.stderr", new=stderr): self.assertEqual(self.module.main(), 1) self.assertIn( diff --git a/release/scripts/validate-upgrade-exercise.py b/release/scripts/validate-upgrade-exercise.py index dc0f58d0b..af0684fcb 100644 --- a/release/scripts/validate-upgrade-exercise.py +++ b/release/scripts/validate-upgrade-exercise.py @@ -545,7 +545,7 @@ def main() -> int: validate_record( data, allow_template=template, - require_all_passed=args.require_pass and not template, + require_all_passed=not template, ) print(f"upgrade exercise discovery passed: {len(records)} record(s)") return 0 From a6aa976217d396b57c528db6a9fdb86535dc3050 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 23 Jul 2026 21:56:49 +0700 Subject: [PATCH 19/58] test(relay): make connection failure deterministic Signed-off-by: Jeremi Joslin --- crates/registry-relay/src/main.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) 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}" From 3e07a6242d3340a477ac78f1dbf8796437817d00 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 23 Jul 2026 22:27:13 +0700 Subject: [PATCH 20/58] docs: generalize evidence consumer terminology Signed-off-by: Jeremi Joslin --- crates/registryctl/CHANGELOG.md | 2 +- .../fixtures/project-authoring-journeys.yaml | 2 +- .../project-authoring/dhis2-tracker/README.md | 8 ++--- .../snapshot-exact/README.md | 9 ++--- docs/site/src/content/docs/changelog.mdx | 4 +-- .../content/docs/explanation/architecture.mdx | 33 ++++++++++++++----- .../disclosure-modes-and-computed-answers.mdx | 14 ++++---- .../docs/explanation/integration-patterns.mdx | 10 +++--- .../docs/explanation/records-stay-home.mdx | 24 +++++++++----- .../src/content/docs/reference/glossary.mdx | 21 ++++++++---- .../src/content/docs/start/when-to-use.mdx | 11 ++++--- .../tutorials/author-registry-project.mdx | 16 +++++---- .../configure-project-script-adapter.mdx | 9 ++--- ...igure-project-snapshot-materialization.mdx | 5 +-- .../generated/project-authoring-journeys.json | 2 +- .../src/data/generated/project-starters.json | 2 +- products/notary/README.md | 10 +++--- products/notary/docs/README.md | 6 ++-- products/notary/docs/architecture-overview.md | 24 +++++++------- products/notary/docs/client-sdk-guide.md | 4 +-- .../notary/docs/notary-capability-matrix.md | 13 ++++---- .../notary/docs/notary-scenario-patterns.md | 14 ++++---- .../docs/self-attestation-operator-guide.md | 13 +++++--- .../docs/source-claim-modeling-guide.md | 33 ++++++++++++------- 24 files changed, 175 insertions(+), 114 deletions(-) diff --git a/crates/registryctl/CHANGELOG.md b/crates/registryctl/CHANGELOG.md index 5aa49b2b8..4c4e27d19 100644 --- a/crates/registryctl/CHANGELOG.md +++ b/crates/registryctl/CHANGELOG.md @@ -9,7 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Changed - The maintained project-authoring fixtures now keep Relay source adaptation, - Notary evidence policy, and consuming programme decisions separate. The + 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 diff --git a/crates/registryctl/tests/fixtures/project-authoring-journeys.yaml b/crates/registryctl/tests/fixtures/project-authoring-journeys.yaml index 93288cb2a..ace5ca33a 100644 --- a/crates/registryctl/tests/fixtures/project-authoring-journeys.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring-journeys.yaml @@ -38,7 +38,7 @@ workspaces: - id: dhis2-tracker label: DHIS2 Tracker - summary: Bounded DHIS2 Tracker acquisition normalized into reusable health evidence for downstream programme decisions. + 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/dhis2-tracker/README.md b/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/README.md index dcbc5664a..8f880d548 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/README.md +++ b/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/README.md @@ -30,10 +30,10 @@ The authored layers remain separate: `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. A consuming programme applies its own reviewed rules. For example, a - programme 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 +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 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 c7770fbaf..e20d333f6 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/snapshot-exact/README.md +++ b/crates/registryctl/tests/fixtures/project-authoring/snapshot-exact/README.md @@ -22,7 +22,8 @@ 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 consuming programme, not this project, combines that evidence with its own -eligibility, prioritization, payment, or workflow rules. A no-match keeps both -evidence values unknown rather than silently converting missing evidence to a -negative fact. +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/docs/site/src/content/docs/changelog.mdx b/docs/site/src/content/docs/changelog.mdx index 614786748..f43fe4caf 100644 --- a/docs/site/src/content/docs/changelog.mdx +++ b/docs/site/src/content/docs/changelog.mdx @@ -18,9 +18,9 @@ relevant product pages on this site rather than duplicating release notes. ## 2026-07-23 -- Made the Relay-to-Notary-to-consuming-programme evidence boundary normative for 1.0 project +- 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 eligibility, prioritization, workflow, and action rules. + from consumer-owned requirements, decisions, workflow, and action rules. ## 2026-07-20 diff --git a/docs/site/src/content/docs/explanation/architecture.mdx b/docs/site/src/content/docs/explanation/architecture.mdx index 3a7b4b947..f313c909a 100644 --- a/docs/site/src/content/docs/explanation/architecture.mdx +++ b/docs/site/src/content/docs/explanation/architecture.mdx @@ -64,24 +64,39 @@ updated policy documents without touching deployment config. topologies." />
-## Evidence and programme decisions +## 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 - programme consequence to those outputs. + 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 - programme rule. -4. A consuming programme owns eligibility, prioritization, workflow, and action. It combines the - evidence with its own rules and remains accountable for the resulting decision. + 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 consuming programme decision and action policy. -Project authors must keep them separate even when one deployment operates every component. +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. @@ -108,8 +123,8 @@ the source's policy. `application/vnd.registry-notary.claim-result+json` 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. A consuming programme evaluates eligibility, priority, workflow, and action from the returned - evidence. Those programme decisions are outside Relay and Notary. +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. 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 dca4be126..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 @@ -20,7 +20,7 @@ You will see one product term used throughout: an **evidence claim**, which is o 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 programme's eligibility or action rules could not be +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 @@ -63,17 +63,19 @@ A **`cel`** rule derives an evidence value from typed outputs, request variables 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 programme decision +## 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 consuming programme separately owns -eligibility, prioritization, workflow, and action policy. +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 -programme eligibility engine. The consuming programme can combine those evidence claims with its -own thresholds, priorities, and case state to decide whether outreach or follow-up is required. +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 diff --git a/docs/site/src/content/docs/explanation/integration-patterns.mdx b/docs/site/src/content/docs/explanation/integration-patterns.mdx index 64ed43ac8..3c2d10613 100644 --- a/docs/site/src/content/docs/explanation/integration-patterns.mdx +++ b/docs/site/src/content/docs/explanation/integration-patterns.mdx @@ -68,15 +68,17 @@ such as a social-protection management information system do not become raw-reco 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 consuming programme -owns eligibility, prioritization, workflow, and action. +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. The consuming programme can then compute `outreach-required` or -`follow-up-priority` without moving either decision into Relay or Notary. +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 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 787259ca0..9d2b7ddf7 100644 --- a/docs/site/src/content/docs/explanation/records-stay-home.mdx +++ b/docs/site/src/content/docs/explanation/records-stay-home.mdx @@ -26,7 +26,8 @@ guarantee. 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, and the consuming programme decides what to do.** +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. @@ -51,7 +52,7 @@ flowchart LR notary -. records .-> audit key -. signs .-> notary end - caller["Consuming programme / verifier"] + caller["Caller / evidence consumer"] holder(["Subject / wallet"]) caller == "request: claim id + subject inputs + scope" ==> notary notary == "evidence: yes/no · value · evaluation result" ==> caller @@ -77,9 +78,12 @@ 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 consuming programme owns eligibility, prioritization, workflow, and action 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 programme decision. +the consumer decision. The caller that invokes Notary can be the evidence consumer or a technical +intermediary acting for it. ## What stays home @@ -156,8 +160,9 @@ rather than a `false`, which hides the reason matching failed. An authorized cal 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 report whether a source records a vaccination dose, derive that evidence predicate from the -declared typed source output. The consuming programme can combine the result with other evidence -and its own case state to decide whether outreach or follow-up is required. +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. @@ -226,9 +231,10 @@ Security material: 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 programme decision engine: Notary returns governed evidence. The consuming - programme owns eligibility, prioritization, workflow, and action unless Notary is attesting a - decision explicitly owned by the authoritative source. +- 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 5b239f9b0..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.
@@ -112,7 +124,7 @@ Product names are always in English, including on future translated pages.
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.
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 a consuming programme's eligibility or action rules.
+
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.
@@ -136,10 +148,10 @@ Product names are always in English, including on future translated pages.
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.
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 consuming programme consequence. `true`, `false`, `null`, `no_match`, `ambiguous`, and source failure retain distinct meanings under the reviewed integration contract.
+
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
-
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 programme policy.
+
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.
@@ -192,9 +204,6 @@ Product names are always in English, including on future translated pages.
policy hash
Lowercase `sha256:` digest that binds a governed evidence policy identity to canonical policy bytes or an externally supplied policy artifact. Manifest verifies it when an inline evidence pack policy object is present; Relay records the selected hash in governed PDP audit provenance.
-
programme decision
-
An eligibility, prioritization, workflow, or action decision made by a consuming programme from evidence and its own rules. Registry Relay and Registry Notary do not own this policy or decision.
-
Protected Registry APIs
Registry Stack runtime pattern for exposing existing registry source data through scoped, read-only HTTP routes with authentication, authorization, metadata, and audit. Registry Relay implements this pattern.
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 8a7e8760e..310eb3202 100644 --- a/docs/site/src/content/docs/start/when-to-use.mdx +++ b/docs/site/src/content/docs/start/when-to-use.mdx @@ -40,8 +40,9 @@ 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 evidence claims and returns a status, predicate, selected - value, or signed credential, instead of the full source record. A consuming programme uses that - evidence under its own eligibility and action policy. + 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. */}
@@ -95,9 +96,9 @@ Registry Stack deliberately leaves these to other systems: 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 - consuming programme combines that evidence with its own decision rules. Notary can attest a - decision already made by an authoritative source only when the claim identifies the decision as - source-owned. + 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 b3a7f40e7..f37e86262 100644 --- a/docs/site/src/content/docs/tutorials/author-registry-project.mdx +++ b/docs/site/src/content/docs/tutorials/author-registry-project.mdx @@ -212,21 +212,25 @@ Registryctl rejects any other target path before generating Relay or Notary conf 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 consuming programme keeps its eligibility, -prioritization, workflow, and action policy outside the project. +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 programme decision? +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 programme reuse the evidence without importing the first programme's - eligibility, ranking, workflow, or action rules? +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 @@ -246,7 +250,7 @@ second project or copying generated product configuration. Use a stable integrat 3. Under the consuming evidence service, add a consultation with `integration: benefit-record` and bind every integration input to one typed request path. Add atomic evidence claims that select or derive from that consultation's declared outputs. Keep - consuming programme decisions outside the evidence service. + 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 c66fe2b36..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,7 +29,7 @@ evidence only; any reviewed source system can use the same script capability and -## Keep DHIS2 evidence separate from programme action +## Keep DHIS2 evidence separate from consumer action The bundled health-evidence journey follows one responsibility flow: @@ -39,11 +39,12 @@ The bundled health-evidence journey follows one responsibility flow: 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. The consuming programme combines the evidence with its own rules and case state to decide - whether outreach, follow-up, or another action is required. +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 consuming programme decisions. A source-owned decision is the exception: a +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. 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 3ff3d0595..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 @@ -145,8 +145,9 @@ declared request identifier. This Notary-owned service defines its service polic 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 consuming programme decision such as -`benefits-eligible`. The consuming programme owns the eligibility and action rules. +`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 diff --git a/docs/site/src/data/generated/project-authoring-journeys.json b/docs/site/src/data/generated/project-authoring-journeys.json index d9d6bcd56..04f100bbc 100644 --- a/docs/site/src/data/generated/project-authoring-journeys.json +++ b/docs/site/src/data/generated/project-authoring-journeys.json @@ -76,7 +76,7 @@ { "id": "dhis2-tracker", "label": "DHIS2 Tracker", - "summary": "Bounded DHIS2 Tracker acquisition normalized into reusable health evidence for downstream programme decisions.", + "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/docs/site/src/data/generated/project-starters.json b/docs/site/src/data/generated/project-starters.json index 221687105..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": "Bounded DHIS2 Tracker acquisition normalized into reusable health evidence for downstream programme decisions.", + "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/products/notary/README.md b/products/notary/README.md index 614ce104d..1bb7d8f2c 100644 --- a/products/notary/README.md +++ b/products/notary/README.md @@ -24,10 +24,12 @@ 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 consuming programme owns eligibility, entitlement, prioritization, -referral, payment, and workflow decisions. 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 programme policy. +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 diff --git a/products/notary/docs/README.md b/products/notary/docs/README.md index fe4f7f1f9..05420a4d6 100644 --- a/products/notary/docs/README.md +++ b/products/notary/docs/README.md @@ -44,6 +44,6 @@ 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 evidence-claim policy language. Product and version metadata never selects -either runtime. The consuming programme owns eligibility, prioritization, -workflow, and action policy; CEL is not a general-purpose programme decision -engine. +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 e9d0f9926..93710ca87 100644 --- a/products/notary/docs/architecture-overview.md +++ b/products/notary/docs/architecture-overview.md @@ -30,18 +30,19 @@ verifies their full semantic contract before serving. outputs only on `match` and closed acquisition provenance. 5. Notary derives direct and CEL evidence claims, applies disclosure, and either returns the result or issues an allowed credential. -6. The consuming programme applies its eligibility, prioritization, workflow, - or action policy to the returned evidence. +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 - Programme[Consuming programme
eligibility, workflow, action] --> Notary[Registry Notary
evidence, 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 --> Programme + Notary --> Consumer ``` Notary never treats a Relay failure as `no_match`. Ambiguity, denial, source, @@ -52,9 +53,9 @@ 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 authorization decision, not a programme 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 @@ -62,10 +63,11 @@ 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, reusable evidence claims, CEL evidence derivation, disclosure, credential profiles, -signing, and issuance. The consuming programme owns eligibility, entitlement, -prioritization, referral, payment, and workflow policy. Their state and audit -authority remain separate. +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 programme policy. +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 d7257eb95..d68c5749d 100644 --- a/products/notary/docs/client-sdk-guide.md +++ b/products/notary/docs/client-sdk-guide.md @@ -485,8 +485,8 @@ 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 consuming programme's -eligibility rule. +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 6da391c5f..6a5938705 100644 --- a/products/notary/docs/notary-capability-matrix.md +++ b/products/notary/docs/notary-capability-matrix.md @@ -29,7 +29,7 @@ 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 | -| Programme administrator | Define eligibility policy, evidence requirements, and acceptable issuers | Social protection ministry, agriculture programme 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 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 | @@ -43,17 +43,18 @@ scenario patterns](notary-scenario-patterns.md). | 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 | Consumes evidence and owns eligibility, prioritization, workflow, and action 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 consuming programme makes any -eligibility, entitlement, payment, referral, or workflow decision. A Notary -claim may attest a source-owned decision, but Notary does not recompute that -decision as programme policy. +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 | | --- | --- | --- | --- | --- | diff --git a/products/notary/docs/notary-scenario-patterns.md b/products/notary/docs/notary-scenario-patterns.md index 3e3b47fc1..a727ce5a7 100644 --- a/products/notary/docs/notary-scenario-patterns.md +++ b/products/notary/docs/notary-scenario-patterns.md @@ -4,25 +4,26 @@ ```mermaid sequenceDiagram - participant Programme as Consuming programme + participant Consumer as Evidence consumer participant Notary as Registry Notary participant Relay as Registry Relay participant Source as Registry source - Programme->>Notary: Evaluate evidence 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-->>Programme: Minimized evidence or credential - Programme->>Programme: Apply eligibility, workflow, and action policy + Notary-->>Consumer: Minimized evidence or credential + Consumer->>Consumer: Use evidence under applicable policy ``` One consultation can support several direct and CEL evidence claims. Relay returns typed outputs, Notary owns evidence meaning and disclosure, and the -consuming programme owns its eligibility and action rules. +evidence consumer determines how the evidence is used. The decision owner +remains accountable for requirements, decisions, workflow, and actions. ## Self-attested Notary-only evaluation @@ -38,7 +39,8 @@ sequenceDiagram ``` This topology performs no Relay or registry-source call. The identity token -authorizes subject-bound access; it does not establish programme eligibility. +authorizes subject-bound access; it does not establish consumer eligibility +or another consumer-owned outcome. ## Delegated evaluation diff --git a/products/notary/docs/self-attestation-operator-guide.md b/products/notary/docs/self-attestation-operator-guide.md index aaf2e7615..81f37dc36 100644 --- a/products/notary/docs/self-attestation-operator-guide.md +++ b/products/notary/docs/self-attestation-operator-guide.md @@ -58,15 +58,16 @@ caller-supplied identity context is rejected before claim evaluation. Use self-attestation when: - A citizen portal evaluates allowed evidence claims for the token-bound - subject before the programme applies its own eligibility policy. + 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 programme eligibility, and Registry Notary -does not turn it into an eligibility decision. +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: @@ -333,8 +334,10 @@ Confirm that: - 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; and -- the consuming programme, not Registry Notary, owns eligibility, entitlement, - prioritization, referral, payment, and workflow decisions. +- 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 7c5e05773..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, reusable evidence in -Notary, and programme decisions in the consuming system. +Notary, and consumer decisions in the consuming system. ## Choose the topology @@ -15,7 +15,7 @@ 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 programme decisions +## 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: @@ -24,16 +24,23 @@ authoring. Use it for every registry-backed flow: Source system -> Registry Relay: source-specific acquisition and typed normalization -> Registry Notary: atomic, precise, reusable evidence statements - -> Consuming programme: eligibility, prioritization, workflow, and action + -> Evidence consumer: use evidence under consumer-owned policy + -> Decision owner: remain accountable for requirements and outcomes ``` -The three stages use different policy: +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. -| Owner | Policy it owns | +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 | -| Consuming programme | Eligibility, entitlement, prioritization, referral, payment, and case workflow | +| 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 @@ -58,7 +65,7 @@ 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 a consuming programme's eligibility or action rules. +encode an evidence consumer's decision or action rules. ## Model the Notary service @@ -68,13 +75,15 @@ 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 evidence predicates or derived evidence values. CEL is not a source adapter, cannot -perform I/O, and is not a general-purpose programme eligibility engine. +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 programmes can reuse. The consuming programme -combines those claims with its own policy after Notary returns the evidence. +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 @@ -83,8 +92,8 @@ 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 programme can reuse the statement without importing the first - programme's decision rules. +- 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. From c79e29e588a5ba146d290bceb209a64e21cdf0d8 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 23 Jul 2026 22:47:49 +0700 Subject: [PATCH 21/58] fix governed integration evidence contracts Signed-off-by: Jeremi Joslin --- .../src/project_authoring/commands.rs | 32 +++++++- .../src/project_authoring/tests.rs | 74 ++++++++++++++++++- .../project-authoring/openspp-exact/README.md | 11 ++- .../tutorials/first-run-with-solmara-lab.mdx | 9 ++- .../integrations/pilot-report.template.md | 3 +- release/scripts/test_integration_e2_runner.py | 5 ++ 6 files changed, 122 insertions(+), 12 deletions(-) diff --git a/crates/registryctl/src/project_authoring/commands.rs b/crates/registryctl/src/project_authoring/commands.rs index e819d01a6..21aceeff4 100644 --- a/crates/registryctl/src/project_authoring/commands.rs +++ b/crates/registryctl/src/project_authoring/commands.rs @@ -721,6 +721,27 @@ fn validate_live_response( let result = result .as_object() .ok_or_else(|| anyhow!("governed Notary result must be an object"))?; + if result.keys().any(|key| { + !matches!( + key.as_str(), + "evaluation_id" + | "claim_id" + | "claim_version" + | "subject_type" + | "requester_ref" + | "target_ref" + | "value" + | "satisfied" + | "disclosure" + | "redacted_fields" + | "format" + | "issued_at" + | "expires_at" + | "provenance" + ) + }) { + bail!("governed Notary result has an unsupported field"); + } let claim_id = result .get("claim_id") .and_then(Value::as_str) @@ -733,12 +754,15 @@ fn validate_live_response( .ok_or_else(|| anyhow!("live expected claim result must be an object"))?; if expected_result .keys() - .any(|key| !matches!(key.as_str(), "value" | "satisfied" | "disclosure")) - || expected_result.is_empty() + .map(String::as_str) + .collect::>() + != BTreeSet::from(["disclosure", "satisfied", "value"]) { - bail!("live expected claim result has an unsupported field"); + bail!( + "live expected claim result must contain exactly value, satisfied, and disclosure" + ); } - for field in expected_result.keys() { + for field in ["value", "satisfied", "disclosure"] { if result.get(field) != expected_result.get(field) { bail!("governed Notary disclosed claim result did not match the expected fixture"); } diff --git a/crates/registryctl/src/project_authoring/tests.rs b/crates/registryctl/src/project_authoring/tests.rs index a5982b1fd..e9eff38a3 100644 --- a/crates/registryctl/src/project_authoring/tests.rs +++ b/crates/registryctl/src/project_authoring/tests.rs @@ -659,11 +659,21 @@ outputs: #[test] fn governed_live_result_requires_exact_disclosure_and_source_provenance() { let claims = vec!["eligible".to_string()]; - let expected = json!({ "claims": { "eligible": { "satisfied": true } } }); + let expected = json!({ + "claims": { + "eligible": { + "value": true, + "satisfied": true, + "disclosure": "predicate", + }, + }, + }); let response = json!({ "results": [{ "claim_id": "eligible", + "value": true, "satisfied": true, + "disclosure": "predicate", "provenance": { "used": { "relay_consultation_count": 1 } }, }], }); @@ -683,6 +693,66 @@ outputs: ); } + #[test] + fn governed_live_result_rejects_partial_disclosure_expectations() { + let claims = vec!["eligible".to_string()]; + let expected = json!({ + "claims": { + "eligible": { + "satisfied": true, + "disclosure": "predicate", + }, + }, + }); + let response = json!({ + "results": [{ + "claim_id": "eligible", + "value": "unexpected source value", + "satisfied": true, + "disclosure": "predicate", + "provenance": { "used": { "relay_consultation_count": 1 } }, + }], + }); + + assert!( + validate_live_response(&response, &claims, &expected) + .expect_err("partial expected disclosure must fail closed") + .to_string() + .contains("must contain exactly value, satisfied, and disclosure") + ); + } + + #[test] + fn governed_live_result_rejects_unknown_result_fields() { + let claims = vec!["eligible".to_string()]; + let expected = json!({ + "claims": { + "eligible": { + "value": true, + "satisfied": true, + "disclosure": "predicate", + }, + }, + }); + let response = json!({ + "results": [{ + "claim_id": "eligible", + "value": true, + "satisfied": true, + "disclosure": "predicate", + "raw_value": "unexpected source value", + "provenance": { "used": { "relay_consultation_count": 1 } }, + }], + }); + + assert!( + validate_live_response(&response, &claims, &expected) + .expect_err("unknown result fields must fail closed") + .to_string() + .contains("result has an unsupported field") + ); + } + #[test] fn governed_live_request_negotiates_the_claim_result_media_type() { let endpoint = @@ -702,6 +772,7 @@ outputs: let expected = json!({ "claims": { "record-exists": { + "value": false, "satisfied": false, "disclosure": "predicate", }, @@ -710,6 +781,7 @@ outputs: let response = json!({ "results": [{ "claim_id": "record-exists", + "value": false, "satisfied": false, "disclosure": "predicate", "provenance": { "used": { "relay_consultation_count": 1 } }, diff --git a/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md b/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md index 1de395fd8..2dc5eece8 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md +++ b/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md @@ -113,8 +113,10 @@ request's `format`, and validates that claim-result envelope. The expected-result file must contain only a `claims` object. Its keys must exactly match the request's claim ids. -Each claim value must be a non-empty object containing only the exact `value`, -`satisfied`, or `disclosure` fields that the owner intends to verify. +Each claim value must contain exactly `value`, `satisfied`, and `disclosure`, +using `null` when the Notary result has no value or satisfaction decision. +The live runner compares all three fields and rejects unrecognized result +fields so an over-disclosed response cannot pass as evidence. This example reflects only the committed synthetic fixture and must be replaced with reviewed expectations for the owner-approved record: @@ -122,18 +124,23 @@ with reviewed expectations for the owner-approved record: { "claims": { "social-registry-record-exists": { + "value": true, "satisfied": true, "disclosure": "predicate" }, "social-registry-active": { + "value": true, "satisfied": true, "disclosure": "predicate" }, "programme-code": { "value": "SUPPORT", + "satisfied": null, "disclosure": "value" }, "household-reference": { + "value": null, + "satisfied": null, "disclosure": "redacted" } } 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 b33e3dfcc..501ff9f33 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,11 @@ 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. Solmara Lab does not yet have an immutable release. The -final 1.0 release-candidate reader run remains gated in -[GH#198](https://github.com/registrystack/registry-stack/issues/198) on a tagged Registry Stack -candidate and an atomically pinned Solmara Lab release. +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 pinned atomically to that candidate. +{/* 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/release/conformance/integrations/pilot-report.template.md b/release/conformance/integrations/pilot-report.template.md index aa4a0f062..bc891da36 100644 --- a/release/conformance/integrations/pilot-report.template.md +++ b/release/conformance/integrations/pilot-report.template.md @@ -11,7 +11,8 @@ evidence. One completed pilot is not proof of broad production readiness. ## Closing evidence -- Sanitized run result: [link to the schema-valid public result] +- 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] diff --git a/release/scripts/test_integration_e2_runner.py b/release/scripts/test_integration_e2_runner.py index 422fbda27..c454e0a99 100644 --- a/release/scripts/test_integration_e2_runner.py +++ b/release/scripts/test_integration_e2_runner.py @@ -282,6 +282,11 @@ def test_pilot_report_template_preserves_the_public_contract(self) -> None: 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) for text in ( "Sanitized run result:", "Plans, dry runs", From a71b35fe170e9e89024f7dacc2c38f5a40724802 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 23 Jul 2026 22:47:54 +0700 Subject: [PATCH 22/58] fix(release): authenticate conformance inputs Signed-off-by: Jeremi Joslin --- release/conformance/relay-oidc/README.md | 10 +- release/scripts/conformance_candidate.py | 217 +++++++++++++++++- release/scripts/openid-conformance-runner.py | 6 +- release/scripts/relay-oidc-smoke.py | 7 +- .../scripts/test_openid_conformance_runner.py | 28 +++ release/scripts/test_relay_oidc_smoke.py | 188 ++++++++++++++- 6 files changed, 444 insertions(+), 12 deletions(-) diff --git a/release/conformance/relay-oidc/README.md b/release/conformance/relay-oidc/README.md index c3e36ea18..96f715e1a 100644 --- a/release/conformance/relay-oidc/README.md +++ b/release/conformance/relay-oidc/README.md @@ -9,6 +9,11 @@ The topology uses digest-pinned Zitadel, PostgreSQL, and Python images. The 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. ## Evidence boundary @@ -66,10 +71,11 @@ 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 release/scripts/relay-oidc-smoke.py validate diff --git a/release/scripts/conformance_candidate.py b/release/scripts/conformance_candidate.py index e3fa84170..6762f4864 100644 --- a/release/scripts/conformance_candidate.py +++ b/release/scripts/conformance_candidate.py @@ -7,10 +7,11 @@ import hashlib import json import re +import shutil import stat import subprocess from pathlib import Path -from typing import Any +from typing import Any, Callable import yaml @@ -23,6 +24,12 @@ "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): @@ -86,6 +93,207 @@ def require_regular_file(path: Path, *, max_bytes: int) -> 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: + result = subprocess.run(command, text=True, capture_output=True, check=False) + 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( manifest_path: Path, image_lock_path: Path, @@ -169,6 +377,10 @@ def load_candidate( ): 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 ( @@ -187,7 +399,8 @@ def load_candidate( "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:{hashlib.sha256(image_lock_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, diff --git a/release/scripts/openid-conformance-runner.py b/release/scripts/openid-conformance-runner.py index 09b2bacea..52fe28a2b 100755 --- a/release/scripts/openid-conformance-runner.py +++ b/release/scripts/openid-conformance-runner.py @@ -413,12 +413,14 @@ def cmd_submit_offer(args: argparse.Namespace) -> int: endpoint = urllib.parse.urlsplit(args.suite_offer_endpoint) if ( (endpoint.scheme, endpoint.netloc) != (base.scheme, base.netloc) - or endpoint.scheme not in {"http", "https"} + or endpoint.scheme != "https" or not endpoint.path.endswith("/credential_offer") or endpoint.query or endpoint.fragment ): - raise RunnerError("suite offer endpoint must use the pinned suite origin") + 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})) ) diff --git a/release/scripts/relay-oidc-smoke.py b/release/scripts/relay-oidc-smoke.py index df3edfbb2..d7b898c4d 100755 --- a/release/scripts/relay-oidc-smoke.py +++ b/release/scripts/relay-oidc-smoke.py @@ -171,12 +171,15 @@ def plan_document(candidate: dict[str, Any]) -> dict[str, Any]: "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." diff --git a/release/scripts/test_openid_conformance_runner.py b/release/scripts/test_openid_conformance_runner.py index 35a7ab47d..be5fa478c 100755 --- a/release/scripts/test_openid_conformance_runner.py +++ b/release/scripts/test_openid_conformance_runner.py @@ -162,6 +162,34 @@ def test_submit_offer_forwards_only_the_real_notary_preauthorized_offer( self.runner.cmd_submit_offer(args) self.assertNotIn("owner-only-code", str(caught.exception)) + 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) diff --git a/release/scripts/test_relay_oidc_smoke.py b/release/scripts/test_relay_oidc_smoke.py index b5190c902..b0bbd1f6a 100644 --- a/release/scripts/test_relay_oidc_smoke.py +++ b/release/scripts/test_relay_oidc_smoke.py @@ -4,6 +4,7 @@ import base64 import contextlib +import hashlib import importlib.util import json import sys @@ -149,6 +150,62 @@ 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 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") @@ -162,11 +219,11 @@ 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_plan_is_offline_and_does_not_claim_live_evidence(self) -> None: + def test_plan_declares_authenticity_network_and_no_live_evidence(self) -> None: candidate = {"release_id": "beta-17", "source_ref": "a" * 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"]) @@ -196,16 +253,20 @@ def test_candidate_binding_rejects_manifest_lock_and_image_mismatches(self) -> N ), }, } - image_lock.write_text(json.dumps(lock), encoding="utf-8") + self.write_candidate_evidence(image_lock, lock) args = Namespace( release_manifest=manifest, image_lock=image_lock, topology="release-owned", solmara_source_ref=None, ) - candidate = self.runner.candidate_from_args(args) + 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") @@ -228,6 +289,125 @@ def test_candidate_binding_rejects_manifest_lock_and_image_mismatches(self) -> N 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_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_rejects_promoted_manifest_not_present_at_tag( self, ) -> None: From f22f83389a00ff3a86a3efd4dae9b03cf5e353fd Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 23 Jul 2026 22:48:11 +0700 Subject: [PATCH 23/58] fix(platform): reject pasted waiver credentials Signed-off-by: Jeremi Joslin --- crates/registry-platform-ops/src/lib.rs | 131 +++++++++++++++++++++--- 1 file changed, 118 insertions(+), 13 deletions(-) diff --git a/crates/registry-platform-ops/src/lib.rs b/crates/registry-platform-ops/src/lib.rs index 8ff4881a5..be2b27525 100644 --- a/crates/registry-platform-ops/src/lib.rs +++ b/crates/registry-platform-ops/src/lib.rs @@ -1202,6 +1202,7 @@ pub enum DeploymentWaiverMetadataError { ReferenceLength, ReferenceNotCanonical, ReferenceInvalidSyntax, + ReferenceCredentialLiteral, SummaryLength, SummaryNotCanonical, SummaryControlCharacter, @@ -1211,9 +1212,10 @@ pub enum DeploymentWaiverMetadataError { impl DeploymentWaiverMetadataError { pub const fn field(self) -> &'static str { match self { - Self::ReferenceLength | Self::ReferenceNotCanonical | Self::ReferenceInvalidSyntax => { - "reference" - } + Self::ReferenceLength + | Self::ReferenceNotCanonical + | Self::ReferenceInvalidSyntax + | Self::ReferenceCredentialLiteral => "reference", Self::SummaryLength | Self::SummaryNotCanonical | Self::SummaryControlCharacter @@ -1237,6 +1239,10 @@ impl Display for DeploymentWaiverMetadataError { 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" @@ -1273,6 +1279,9 @@ pub fn validate_deployment_waiver_metadata( 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(()); @@ -1292,8 +1301,30 @@ pub fn validate_deployment_waiver_metadata( 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 { - ["Bearer", "Basic"] + AUTHORIZATION_SCHEMES .iter() .any(|scheme| contains_authorization_value(summary, scheme)) || summary.split("-----BEGIN ").skip(1).any(|suffix| { @@ -1304,22 +1335,48 @@ fn contains_high_confidence_credential_literal(summary: &str) -> bool { } fn contains_authorization_value(summary: &str, scheme: &str) -> bool { - let mut words = summary.split_whitespace(); - let Some(mut candidate_scheme) = words.next() else { - return false; - }; + let mut value_follows = false; + for word in summary.split_whitespace() { + if value_follows { + value_follows = false; + if is_credential_like_authorization_value(word) { + return true; + } + } - for candidate_value in words { - if candidate_scheme.eq_ignore_ascii_case(scheme) - && is_credential_like_authorization_value(candidate_value) - { + let Some(inline_value) = authorization_value_in_word(word, scheme) else { + continue; + }; + if inline_value.is_empty() { + value_follows = true; + } else if is_credential_like_authorization_value(inline_value) { return true; } - candidate_scheme = candidate_value; } false } +fn authorization_value_in_word<'a>(word: &'a str, scheme: &str) -> Option<&'a str> { + let mut segment_start = 0; + for segment in word.split(':') { + let segment_end = segment_start + segment.len(); + // Normalize punctuation surrounding a scheme segment while retaining + // internal punctuation, so prose such as "Bearer-based" stays valid. + let normalized_scheme = + segment.trim_matches(|character: char| character.is_ascii_punctuation()); + if normalized_scheme.eq_ignore_ascii_case(scheme) { + if segment_end == word.len() { + return Some(""); + } + return word + .get(segment_end + 1..) + .map(|value| value.trim_start_matches(':')); + } + segment_start = segment_end + 1; + } + None +} + fn is_credential_like_authorization_value(candidate: &str) -> bool { let prose_word = candidate.trim_matches(|character: char| character.is_ascii_punctuation()); !prose_word.is_empty() @@ -3765,6 +3822,11 @@ mod tests { 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"), @@ -3794,6 +3856,20 @@ mod tests { 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] @@ -3848,6 +3924,30 @@ mod tests { "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, + ), + ( + "(bearer): abcdef after config apply", + DeploymentWaiverMetadataError::SummaryCredentialLiteral, + ), + ( + "Bearer:abcdef before config apply", + DeploymentWaiverMetadataError::SummaryCredentialLiteral, + ), + ( + "Bearer:token:abcdef before config apply", + DeploymentWaiverMetadataError::SummaryCredentialLiteral, + ), ( concat!("accidentally pasted -----BEGIN PRIVATE ", "KEY-----"), DeploymentWaiverMetadataError::SummaryCredentialLiteral, @@ -3904,6 +4004,11 @@ mod tests { "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", ] { validate_deployment_waiver_metadata("OPS-42", Some(summary)) .unwrap_or_else(|error| panic!("ordinary summary rejected: {summary:?}: {error}")); From ca2c60cad9b67111dc4dc2a6614855d3b02b7505 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 23 Jul 2026 22:50:08 +0700 Subject: [PATCH 24/58] fix(relay): match pinned oasdiff ignore output Signed-off-by: Jeremi Joslin --- crates/registry-relay/openapi/oasdiff-err-ignore.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/registry-relay/openapi/oasdiff-err-ignore.txt b/crates/registry-relay/openapi/oasdiff-err-ignore.txt index ee56082af..6c7a825c1 100644 --- a/crates/registry-relay/openapi/oasdiff-err-ignore.txt +++ b/crates/registry-relay/openapi/oasdiff-err-ignore.txt @@ -24,4 +24,4 @@ GET /v1/datasets/{dataset_id}/measures/{item_id} added the new path request para # 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` +POST /v1/consultations/{profile_id}/execute added 'subschema #1, subschema #2, subschema #3' to the response body 'oneOf' list for the response status '200' From 47b451a64cb5e2a1c74ceee0626782f42895641d Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 23 Jul 2026 22:52:48 +0700 Subject: [PATCH 25/58] Revert "fix(relay): match pinned oasdiff ignore output" This reverts commit ca2c60cad9b67111dc4dc2a6614855d3b02b7505. Signed-off-by: Jeremi Joslin --- crates/registry-relay/openapi/oasdiff-err-ignore.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/registry-relay/openapi/oasdiff-err-ignore.txt b/crates/registry-relay/openapi/oasdiff-err-ignore.txt index 6c7a825c1..ee56082af 100644 --- a/crates/registry-relay/openapi/oasdiff-err-ignore.txt +++ b/crates/registry-relay/openapi/oasdiff-err-ignore.txt @@ -24,4 +24,4 @@ GET /v1/datasets/{dataset_id}/measures/{item_id} added the new path request para # 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' +POST /v1/consultations/{profile_id}/execute added `subschema #1, subschema #2, subschema #3` to the response body `oneOf` list for the response status `200` From 12652ff96f85fe18fdf1989fbfa9f128d2518fc5 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 23 Jul 2026 23:01:11 +0700 Subject: [PATCH 26/58] fix(release): close conformance trust gaps Signed-off-by: Jeremi Joslin --- release/conformance/openid/README.md | 8 +- release/conformance/relay-oidc/README.md | 3 + release/scripts/conformance_candidate.py | 41 ++++++- release/scripts/openid-conformance-runner.py | 26 ++++- .../scripts/test_openid_conformance_runner.py | 107 +++++++++++++++++- release/scripts/test_relay_oidc_smoke.py | 87 +++++++++----- 6 files changed, 234 insertions(+), 38 deletions(-) diff --git a/release/conformance/openid/README.md b/release/conformance/openid/README.md index 3c503fbd0..08a214cfa 100644 --- a/release/conformance/openid/README.md +++ b/release/conformance/openid/README.md @@ -102,12 +102,16 @@ 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-offer-endpoint 'https://localhost.emobix.co.uk:8443//credential_offer' \ + --suite-ca-certificate /private/path/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. A fabricated offer is not candidate evidence. +and prints no offer content. TLS uses normal hostname and certificate +validation. The optional CA file adds only an explicitly supplied local trust +anchor for the suite's self-signed development certificate. A fabricated offer +is not candidate evidence. Set `REGISTRY_OPENID_CONFORMANCE_AUTHORIZATION_SERVER` when the authorization server differs from the issuer. Set diff --git a/release/conformance/relay-oidc/README.md b/release/conformance/relay-oidc/README.md index 96f715e1a..45668fd15 100644 --- a/release/conformance/relay-oidc/README.md +++ b/release/conformance/relay-oidc/README.md @@ -14,6 +14,9 @@ The image lock must remain in its downloaded release asset directory alongside 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 diff --git a/release/scripts/conformance_candidate.py b/release/scripts/conformance_candidate.py index 6762f4864..10f9a1f09 100644 --- a/release/scripts/conformance_candidate.py +++ b/release/scripts/conformance_candidate.py @@ -74,9 +74,46 @@ def verify_git_binding( 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 object_size != len(manifest_bytes): + if not 0 < object_size <= 1024 * 1024: raise CandidateError("candidate Git binding could not be verified") - if git_output(["show", object_name], object_size) != manifest_bytes: + 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: + if manifest_bytes == tagged_manifest_bytes: + 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") + 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") diff --git a/release/scripts/openid-conformance-runner.py b/release/scripts/openid-conformance-runner.py index 52fe28a2b..608a46b0b 100755 --- a/release/scripts/openid-conformance-runner.py +++ b/release/scripts/openid-conformance-runner.py @@ -424,7 +424,30 @@ def cmd_submit_offer(args: argparse.Namespace) -> int: url = urllib.parse.urlunsplit( endpoint._replace(query=urllib.parse.urlencode({"credential_offer": inline})) ) - context = ssl._create_unverified_context() + ca_certificate = args.suite_ca_certificate + if ca_certificate is not None: + ca_certificate = ca_certificate.expanduser() + try: + info = ca_certificate.lstat() + except OSError: + raise RunnerError( + "suite CA certificate could not be opened securely" + ) from None + if ( + stat.S_ISLNK(info.st_mode) + or 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" + ) + ca_certificate = ca_certificate.resolve() + try: + context = ssl.create_default_context( + cafile=str(ca_certificate) if ca_certificate is not None else None + ) + except OSError: + raise RunnerError("suite CA certificate could not be loaded") from None opener = urllib.request.build_opener( urllib.request.ProxyHandler({}), urllib.request.HTTPSHandler(context=context), @@ -622,6 +645,7 @@ def parse_args(argv: list[str]) -> argparse.Namespace: "--conformance-server", default=load_plan_map()["suite"]["base_url"], ) + offer_parser.add_argument("--suite-ca-certificate", type=Path) offer_parser.add_argument("--timeout-seconds", type=int, default=10) offer_parser.set_defaults(func=cmd_submit_offer) diff --git a/release/scripts/test_openid_conformance_runner.py b/release/scripts/test_openid_conformance_runner.py index be5fa478c..ee1bddd6c 100755 --- a/release/scripts/test_openid_conformance_runner.py +++ b/release/scripts/test_openid_conformance_runner.py @@ -139,17 +139,37 @@ def test_submit_offer_forwards_only_the_real_notary_preauthorized_offer( response.__enter__.return_value.status = 204 opener = MagicMock() opener.open.return_value = response + tls_context = MagicMock() with patch.object( - self.runner.urllib.request, "build_opener", return_value=opener - ): - with patch("builtins.print") as printed: - self.assertEqual(0, self.runner.cmd_submit_offer(args)) + 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(cafile=None) + 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) @@ -162,6 +182,85 @@ def test_submit_offer_forwards_only_the_real_notary_preauthorized_offer( 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 + with patch.object( + self.runner.ssl, + "create_default_context", + return_value=MagicMock(), + ) 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(cafile=str(suite_ca.resolve())) + def test_submit_offer_rejects_cleartext_suite_endpoint(self) -> None: issuer = "https://issuer.example.test" _, offer_uri = self.offer_uri(issuer) diff --git a/release/scripts/test_relay_oidc_smoke.py b/release/scripts/test_relay_oidc_smoke.py index b0bbd1f6a..7c5d7bfb4 100644 --- a/release/scripts/test_relay_oidc_smoke.py +++ b/release/scripts/test_relay_oidc_smoke.py @@ -408,47 +408,76 @@ def test_candidate_authenticity_is_pinned_to_the_tagged_release_workflow( command[command.index("--source-uri") + 1], ) - def test_candidate_binding_rejects_promoted_manifest_not_present_at_tag( + 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" - image_lock.write_text( - json.dumps( - { - "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 - ), - }, - } + lock = { + "schema_version": "registryctl.release_image_lock.v1", + "release_tag": "v0.12.2", + "manifest_source_ref": ( + "0e76f5ea61f78bbc15d91fcb6e9dfcaa956c3df8" ), - encoding="utf-8", + "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=( - self.runner.REPO_ROOT - / "release/manifests/registry-stack-beta-16.yaml" - ), + release_manifest=manifest, image_lock=image_lock, topology="release-owned", solmara_source_ref=None, ) - with self.assertRaisesRegex(candidate_module.CandidateError, "Git binding"): - self.runner.candidate_from_args(args) + 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 + + ( + 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"}}) From dc862e5f7653ad51483c38d850d05863062c8dd6 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 23 Jul 2026 23:05:07 +0700 Subject: [PATCH 27/58] fix(registryctl): validate live results recursively Signed-off-by: Jeremi Joslin --- crates/registry-notary-core/src/model.rs | 6 + .../src/project_authoring/commands.rs | 44 ++--- .../src/project_authoring/tests.rs | 179 ++++++++++++++---- .../project-authoring/openspp-exact/README.md | 7 +- 4 files changed, 170 insertions(+), 66 deletions(-) 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/registryctl/src/project_authoring/commands.rs b/crates/registryctl/src/project_authoring/commands.rs index 21aceeff4..6ddcba20a 100644 --- a/crates/registryctl/src/project_authoring/commands.rs +++ b/crates/registryctl/src/project_authoring/commands.rs @@ -718,34 +718,19 @@ fn validate_live_response( } let mut returned = BTreeSet::new(); for result in results { - let result = result + let result_object = result .as_object() .ok_or_else(|| anyhow!("governed Notary result must be an object"))?; - if result.keys().any(|key| { - !matches!( - key.as_str(), - "evaluation_id" - | "claim_id" - | "claim_version" - | "subject_type" - | "requester_ref" - | "target_ref" - | "value" - | "satisfied" - | "disclosure" - | "redacted_fields" - | "format" - | "issued_at" - | "expires_at" - | "provenance" - ) - }) { - bail!("governed Notary result has an unsupported field"); + 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.derived_from.is_empty() { + bail!("governed Notary result provenance derived_from must remain empty"); } - let claim_id = result - .get("claim_id") - .and_then(Value::as_str) - .ok_or_else(|| anyhow!("governed Notary result lacks a claim_id"))?; + 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"); } @@ -763,16 +748,11 @@ fn validate_live_response( ); } for field in ["value", "satisfied", "disclosure"] { - if result.get(field) != expected_result.get(field) { + 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"); } } diff --git a/crates/registryctl/src/project_authoring/tests.rs b/crates/registryctl/src/project_authoring/tests.rs index e9eff38a3..adc5dc68b 100644 --- a/crates/registryctl/src/project_authoring/tests.rs +++ b/crates/registryctl/src/project_authoring/tests.rs @@ -656,8 +656,51 @@ 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: "Person".to_string(), + requester_ref: Some(registry_notary_core::EvidenceEntityRef { + entity_type: "Organisation".to_string(), + handle: "rnref:v1:requester".to_string(), + identifier_schemes: Vec::new(), + profile: None, + }), + target_ref: registry_notary_core::TargetRefView { + entity_type: "Person".to_string(), + handle: "rnref:v1:target".to_string(), + 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") + } + #[test] - fn governed_live_result_requires_exact_disclosure_and_source_provenance() { + fn governed_live_result_accepts_actual_shape_and_requires_source_provenance() { let claims = vec!["eligible".to_string()]; let expected = json!({ "claims": { @@ -669,13 +712,12 @@ outputs: }, }); let response = json!({ - "results": [{ - "claim_id": "eligible", - "value": true, - "satisfied": true, - "disclosure": "predicate", - "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"), @@ -705,13 +747,12 @@ outputs: }, }); let response = json!({ - "results": [{ - "claim_id": "eligible", - "value": "unexpected source value", - "satisfied": true, - "disclosure": "predicate", - "provenance": { "used": { "relay_consultation_count": 1 } }, - }], + "results": [governed_live_claim_result( + "eligible", + json!("unexpected source value"), + Some(true), + "predicate", + )], }); assert!( @@ -734,22 +775,97 @@ outputs: }, }, }); - let response = json!({ - "results": [{ - "claim_id": "eligible", - "value": true, - "satisfied": true, - "disclosure": "predicate", - "raw_value": "unexpected source value", - "provenance": { "used": { "relay_consultation_count": 1 } }, - }], + 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, &claims, &expected) .expect_err("unknown result fields must fail closed") .to_string() - .contains("result has an unsupported field") + .contains("closed public claim-result schema") + ); + } + + #[test] + fn governed_live_result_rejects_nested_unknown_fields() { + let claims = vec!["eligible".to_string()]; + 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, &claims, &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 claims = vec!["eligible".to_string()]; + 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, &claims, &expected) + .expect_err("non-empty derived_from must fail closed") + .to_string() + .contains("derived_from must remain empty") ); } @@ -779,13 +895,12 @@ outputs: }, }); let response = json!({ - "results": [{ - "claim_id": "record-exists", - "value": false, - "satisfied": false, - "disclosure": "predicate", - "provenance": { "used": { "relay_consultation_count": 1 } }, - }], + "results": [governed_live_claim_result( + "record-exists", + json!(false), + Some(false), + "predicate", + )], }); let returned_claims = validate_live_response(&response, &claims, &expected).expect("no-match result passes"); diff --git a/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md b/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md index 2dc5eece8..0f571228c 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md +++ b/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md @@ -115,8 +115,11 @@ The expected-result file must contain only a `claims` object. Its keys must exactly match the 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. -The live runner compares all three fields and rejects unrecognized result -fields so an over-disclosed response cannot pass as evidence. +The live runner parses each complete result through the recursively closed +public `ClaimResultView`, compares all three disclosure fields, rejects +unrecognized nested fields, and requires the reserved provenance +`derived_from` array to remain empty. An over-disclosed response cannot pass +as evidence. This example reflects only the committed synthetic fixture and must be replaced with reviewed expectations for the owner-approved record: From 1a8bc96322af8d32383786d4fe9ff1560657637f Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 23 Jul 2026 23:13:59 +0700 Subject: [PATCH 28/58] fix(platform): close waiver punctuation bypasses Signed-off-by: Jeremi Joslin --- crates/registry-platform-ops/src/lib.rs | 92 +++++++++++++++++-- crates/registry-relay/docs/configuration.md | 19 +++- .../src/content/docs/spec/rs-op-posture.mdx | 21 +++-- .../notary/docs/operator-config-reference.md | 14 ++- 4 files changed, 125 insertions(+), 21 deletions(-) diff --git a/crates/registry-platform-ops/src/lib.rs b/crates/registry-platform-ops/src/lib.rs index be2b27525..80fe8e62b 100644 --- a/crates/registry-platform-ops/src/lib.rs +++ b/crates/registry-platform-ops/src/lib.rs @@ -1338,6 +1338,9 @@ fn contains_authorization_value(summary: &str, scheme: &str) -> bool { let mut value_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; @@ -1358,27 +1361,32 @@ fn contains_authorization_value(summary: &str, scheme: &str) -> bool { fn authorization_value_in_word<'a>(word: &'a str, scheme: &str) -> Option<&'a str> { let mut segment_start = 0; - for segment in word.split(':') { - let segment_end = segment_start + segment.len(); + 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 punctuation surrounding a scheme segment while retaining // internal punctuation, so prose such as "Bearer-based" stays valid. - let normalized_scheme = - segment.trim_matches(|character: char| character.is_ascii_punctuation()); + let normalized_scheme = segment.trim_matches(is_authorization_separator); if normalized_scheme.eq_ignore_ascii_case(scheme) { - if segment_end == word.len() { + if delimiter_len == 0 { return Some(""); } return word - .get(segment_end + 1..) - .map(|value| value.trim_start_matches(':')); + .get(segment_end + delimiter_len..) + .map(|value| value.trim_start_matches(is_authorization_colon)); } - segment_start = segment_end + 1; + segment_start = segment_end + delimiter_len; } None } fn is_credential_like_authorization_value(candidate: &str) -> bool { - let prose_word = candidate.trim_matches(|character: char| character.is_ascii_punctuation()); + let prose_word = candidate.trim_matches(is_authorization_separator); !prose_word.is_empty() && ![ "access", @@ -1398,6 +1406,43 @@ fn is_credential_like_authorization_value(candidate: &str) -> bool { .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_separator) +} + +fn is_authorization_colon(character: char) -> bool { + matches!(character, ':' | '\u{fe55}' | '\u{ff1a}') +} + +fn is_authorization_separator(character: char) -> bool { + character.is_ascii_punctuation() + || matches!( + character, + // Common quotation marks copied from rich-text systems. + '\u{00ab}' + | '\u{00bb}' + | '\u{2018}'..='\u{201f}' + | '\u{2039}' + | '\u{203a}' + // Common standalone separators. + | '\u{2010}'..='\u{2015}' + | '\u{2022}' + | '\u{2026}' + // CJK quotation and bracket marks. + | '\u{3008}'..='\u{3011}' + | '\u{3014}'..='\u{301b}' + // Small and full-width forms used around copied header syntax. + | '\u{fe55}' + | '\u{ff08}' + | '\u{ff09}' + | '\u{ff1a}' + | '\u{ff3b}' + | '\u{ff3d}' + | '\u{ff5b}' + | '\u{ff5d}' + ) +} + #[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] pub struct BreakGlassApproval { pub approved_by: String, @@ -3948,6 +3993,30 @@ mod tests { "Bearer:token:abcdef before config apply", DeploymentWaiverMetadataError::SummaryCredentialLiteral, ), + ( + "Authorization: “Bearer abcdef”", + 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, @@ -4009,6 +4078,11 @@ mod tests { "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", ] { validate_deployment_waiver_metadata("OPS-42", Some(summary)) .unwrap_or_else(|error| panic!("ordinary summary rejected: {summary:?}: {error}")); diff --git a/crates/registry-relay/docs/configuration.md b/crates/registry-relay/docs/configuration.md index 3a4f885bb..7487d10b4 100644 --- a/crates/registry-relay/docs/configuration.md +++ b/crates/registry-relay/docs/configuration.md @@ -1312,7 +1312,24 @@ deployment: 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 `reference` is 1 to 128 bytes, has no surrounding whitespace, uses only letters, digits, `.`, `_`, `:`, and `-`, and cannot contain `..`. 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. 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. +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. 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 dae9162a5..3e90c7c44 100644 --- a/docs/site/src/content/docs/spec/rs-op-posture.mdx +++ b/docs/site/src/content/docs/spec/rs-op-posture.mdx @@ -43,6 +43,7 @@ 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. | ## 1. Scope and surface @@ -126,13 +127,19 @@ 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 `..`. 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. 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. +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. +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 diff --git a/products/notary/docs/operator-config-reference.md b/products/notary/docs/operator-config-reference.md index 32fd542bb..b447b227b 100644 --- a/products/notary/docs/operator-config-reference.md +++ b/products/notary/docs/operator-config-reference.md @@ -726,10 +726,16 @@ authenticity boundary. 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 `..`. 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. +`_`, `:`, 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: From c263b838cd87f6af6491dab65e5c2384d50dde5d Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 23 Jul 2026 23:16:08 +0700 Subject: [PATCH 29/58] fix(registryctl): align live evidence guarantees Signed-off-by: Jeremi Joslin --- .../src/project_authoring/commands.rs | 5 +++ .../src/project_authoring/tests.rs | 36 +++++++++++++++++++ .../project-authoring/openspp-exact/README.md | 11 +++--- 3 files changed, 47 insertions(+), 5 deletions(-) diff --git a/crates/registryctl/src/project_authoring/commands.rs b/crates/registryctl/src/project_authoring/commands.rs index 6ddcba20a..ac43b3926 100644 --- a/crates/registryctl/src/project_authoring/commands.rs +++ b/crates/registryctl/src/project_authoring/commands.rs @@ -730,6 +730,11 @@ fn validate_live_response( if !result_view.provenance.derived_from.is_empty() { bail!("governed Notary result provenance derived_from must remain empty"); } + if result_view.provenance.generated_by.pack_id.is_some() + || result_view.provenance.generated_by.pack_version.is_some() + { + bail!("governed Notary result exceeds the closed public claim-result schema"); + } 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"); diff --git a/crates/registryctl/src/project_authoring/tests.rs b/crates/registryctl/src/project_authoring/tests.rs index adc5dc68b..adf69d1e4 100644 --- a/crates/registryctl/src/project_authoring/tests.rs +++ b/crates/registryctl/src/project_authoring/tests.rs @@ -869,6 +869,42 @@ outputs: ); } + #[test] + fn governed_live_result_rejects_provenance_fields_outside_public_schema() { + let claims = vec!["eligible".to_string()]; + 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"] { + let mut unsupported = response.clone(); + unsupported["results"][0]["provenance"]["generated_by"][field] = + json!("unsupported"); + + assert!( + validate_live_response(&unsupported, &claims, &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 = diff --git a/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md b/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md index 0f571228c..1757613ec 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md +++ b/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md @@ -115,11 +115,12 @@ The expected-result file must contain only a `claims` object. Its keys must exactly match the 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. -The live runner parses each complete result through the recursively closed -public `ClaimResultView`, compares all three disclosure fields, rejects -unrecognized nested fields, and requires the reserved provenance -`derived_from` array to remain empty. An over-disclosed response cannot pass -as evidence. +The live runner rejects keys outside its accepted result, reference, and +provenance structures; rejects non-null provenance `pack_id` and +`pack_version`; requires exact `value`, `satisfied`, and `disclosure` matches; +requires an empty `derived_from` array; and requires a non-zero Relay +consultation count. It does not judge whether an otherwise allowed handle or +identifier value is semantically appropriate. This example reflects only the committed synthetic fixture and must be replaced with reviewed expectations for the owner-approved record: From 156826bdefb4877f040b8c9201fd53e8ca18dce2 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 23 Jul 2026 23:18:03 +0700 Subject: [PATCH 30/58] fix(release): authenticate local suite TLS Signed-off-by: Jeremi Joslin --- release/conformance/openid/README.md | 17 +- release/conformance/openid/nginx.Dockerfile | 6 +- release/scripts/openid-conformance-runner.py | 156 +++++++++++-- .../scripts/test_openid_conformance_runner.py | 218 +++++++++++++++++- 4 files changed, 362 insertions(+), 35 deletions(-) diff --git a/release/conformance/openid/README.md b/release/conformance/openid/README.md index 08a214cfa..99dcb7667 100644 --- a/release/conformance/openid/README.md +++ b/release/conformance/openid/README.md @@ -95,23 +95,30 @@ 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, then submit it: +`/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 /private/path/conformance-suite-ca.pem + --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 optional CA file adds only an explicitly supplied local trust -anchor for the suite's self-signed development certificate. A fabricated offer -is not candidate evidence. +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 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/scripts/openid-conformance-runner.py b/release/scripts/openid-conformance-runner.py index 608a46b0b..f6411c84b 100755 --- a/release/scripts/openid-conformance-runner.py +++ b/release/scripts/openid-conformance-runner.py @@ -14,6 +14,7 @@ import stat import subprocess import sys +import tempfile import time import urllib.error import urllib.parse @@ -37,6 +38,8 @@ 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): @@ -407,6 +410,68 @@ def read_offer(path: Path, issuer_url: str) -> str: 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) @@ -424,30 +489,7 @@ def cmd_submit_offer(args: argparse.Namespace) -> int: url = urllib.parse.urlunsplit( endpoint._replace(query=urllib.parse.urlencode({"credential_offer": inline})) ) - ca_certificate = args.suite_ca_certificate - if ca_certificate is not None: - ca_certificate = ca_certificate.expanduser() - try: - info = ca_certificate.lstat() - except OSError: - raise RunnerError( - "suite CA certificate could not be opened securely" - ) from None - if ( - stat.S_ISLNK(info.st_mode) - or 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" - ) - ca_certificate = ca_certificate.resolve() - try: - context = ssl.create_default_context( - cafile=str(ca_certificate) if ca_certificate is not None else None - ) - except OSError: - raise RunnerError("suite CA certificate could not be loaded") from None + context = suite_tls_context(args.suite_ca_certificate) opener = urllib.request.build_opener( urllib.request.ProxyHandler({}), urllib.request.HTTPSHandler(context=context), @@ -465,6 +507,55 @@ def cmd_submit_offer(args: argparse.Namespace) -> int: 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, 0o644) + 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() @@ -623,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) @@ -645,7 +747,11 @@ def parse_args(argv: list[str]) -> argparse.Namespace: "--conformance-server", default=load_plan_map()["suite"]["base_url"], ) - offer_parser.add_argument("--suite-ca-certificate", type=Path) + 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) diff --git a/release/scripts/test_openid_conformance_runner.py b/release/scripts/test_openid_conformance_runner.py index ee1bddd6c..31efb5958 100755 --- a/release/scripts/test_openid_conformance_runner.py +++ b/release/scripts/test_openid_conformance_runner.py @@ -5,10 +5,16 @@ 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 MagicMock, patch @@ -16,6 +22,7 @@ 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(): @@ -30,6 +37,24 @@ 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: @@ -163,7 +188,7 @@ def test_submit_offer_forwards_only_the_real_notary_preauthorized_offer( [inline], urllib.parse.parse_qs(submitted.query)["credential_offer"], ) - create_context.assert_called_once_with(cafile=None) + create_context.assert_called_once_with() https_handler = next( handler for handler in build_opener.call_args.args @@ -246,10 +271,11 @@ def test_submit_offer_accepts_an_explicit_local_suite_ca(self) -> None: 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=MagicMock(), + "SSLContext", + return_value=tls_context, ) as create_context: with patch.object( self.runner.urllib.request, @@ -259,7 +285,191 @@ def test_submit_offer_accepts_an_explicit_local_suite_ca(self) -> None: with patch("builtins.print"): self.assertEqual(0, self.runner.cmd_submit_offer(args)) - create_context.assert_called_once_with(cafile=str(suite_ca.resolve())) + 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( + 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.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" From 5e1a9f9bb8610ff4976a42a873b95df6141c59a0 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 23 Jul 2026 23:18:51 +0700 Subject: [PATCH 31/58] fix(registryctl): reject omitted provenance keys Signed-off-by: Jeremi Joslin --- .../src/project_authoring/commands.rs | 8 ++++--- .../src/project_authoring/tests.rs | 23 ++++++++++--------- .../project-authoring/openspp-exact/README.md | 10 ++++---- 3 files changed, 22 insertions(+), 19 deletions(-) diff --git a/crates/registryctl/src/project_authoring/commands.rs b/crates/registryctl/src/project_authoring/commands.rs index ac43b3926..ee33eec54 100644 --- a/crates/registryctl/src/project_authoring/commands.rs +++ b/crates/registryctl/src/project_authoring/commands.rs @@ -730,9 +730,11 @@ fn validate_live_response( if !result_view.provenance.derived_from.is_empty() { bail!("governed Notary result provenance derived_from must remain empty"); } - if result_view.provenance.generated_by.pack_id.is_some() - || result_view.provenance.generated_by.pack_version.is_some() - { + let generated_by = result + .pointer("/provenance/generated_by") + .and_then(Value::as_object) + .ok_or_else(|| anyhow!("governed Notary result has invalid provenance"))?; + if generated_by.contains_key("pack_id") || generated_by.contains_key("pack_version") { bail!("governed Notary result exceeds the closed public claim-result schema"); } let claim_id = result_view.claim_id.as_str(); diff --git a/crates/registryctl/src/project_authoring/tests.rs b/crates/registryctl/src/project_authoring/tests.rs index adf69d1e4..0260e640d 100644 --- a/crates/registryctl/src/project_authoring/tests.rs +++ b/crates/registryctl/src/project_authoring/tests.rs @@ -891,17 +891,18 @@ outputs: }); for field in ["pack_id", "pack_version"] { - let mut unsupported = response.clone(); - unsupported["results"][0]["provenance"]["generated_by"][field] = - json!("unsupported"); - - assert!( - validate_live_response(&unsupported, &claims, &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" - ); + 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, &claims, &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" + ); + } } } diff --git a/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md b/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md index 1757613ec..24e311461 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md +++ b/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md @@ -116,11 +116,11 @@ Its keys must exactly match the 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. The live runner rejects keys outside its accepted result, reference, and -provenance structures; rejects non-null provenance `pack_id` and -`pack_version`; requires exact `value`, `satisfied`, and `disclosure` matches; -requires an empty `derived_from` array; and requires a non-zero Relay -consultation count. It does not judge whether an otherwise allowed handle or -identifier value is semantically appropriate. +provenance structures; does not accept provenance `pack_id` or `pack_version` +keys; requires exact `value`, `satisfied`, and `disclosure` matches; requires +an empty `derived_from` array; and requires a non-zero Relay consultation +count. It does not judge whether an otherwise allowed handle or identifier +value is semantically appropriate. This example reflects only the committed synthetic fixture and must be replaced with reviewed expectations for the owner-approved record: From ab9f032f7c58a3b12b99caa06b86be7ab1b1b3f2 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 23 Jul 2026 23:26:22 +0700 Subject: [PATCH 32/58] fix(release): satisfy TLS security checks Signed-off-by: Jeremi Joslin --- release/scripts/openid-conformance-runner.py | 2 +- release/scripts/test_openid_conformance_runner.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/release/scripts/openid-conformance-runner.py b/release/scripts/openid-conformance-runner.py index f6411c84b..dee5463cb 100755 --- a/release/scripts/openid-conformance-runner.py +++ b/release/scripts/openid-conformance-runner.py @@ -514,7 +514,7 @@ def write_new_file(path: Path, content: bytes) -> Path: flags |= getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0) descriptor: int | None = None try: - descriptor = os.open(path, flags, 0o644) + descriptor = os.open(path, flags, 0o600) with os.fdopen(descriptor, "wb", closefd=True) as handle: descriptor = None handle.write(content) diff --git a/release/scripts/test_openid_conformance_runner.py b/release/scripts/test_openid_conformance_runner.py index 31efb5958..a34c2b491 100755 --- a/release/scripts/test_openid_conformance_runner.py +++ b/release/scripts/test_openid_conformance_runner.py @@ -418,6 +418,7 @@ def copy_container_certificate( 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], @@ -428,6 +429,7 @@ def copy_container_certificate( 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 From 6d8aedf9cf3215b0b34dd8c397e12cf9708ffc00 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 23 Jul 2026 23:32:51 +0700 Subject: [PATCH 33/58] fix(platform): normalize waiver auth boundaries Signed-off-by: Jeremi Joslin --- crates/registry-platform-ops/src/lib.rs | 123 ++++++++++++++++-------- 1 file changed, 81 insertions(+), 42 deletions(-) diff --git a/crates/registry-platform-ops/src/lib.rs b/crates/registry-platform-ops/src/lib.rs index 80fe8e62b..4b8b15422 100644 --- a/crates/registry-platform-ops/src/lib.rs +++ b/crates/registry-platform-ops/src/lib.rs @@ -1336,6 +1336,8 @@ fn contains_high_confidence_credential_literal(summary: &str) -> bool { 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) { @@ -1347,19 +1349,51 @@ fn contains_authorization_value(summary: &str, scheme: &str) -> bool { } } - let Some(inline_value) = authorization_value_in_word(word, scheme) else { + 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 inline_value.is_empty() { + 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(inline_value) { + } else if is_credential_like_authorization_value(authorization_value.value) { return true; } } false } -fn authorization_value_in_word<'a>(word: &'a str, scheme: &str) -> Option<&'a str> { +#[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() @@ -1369,16 +1403,27 @@ fn authorization_value_in_word<'a>(word: &'a str, scheme: &str) -> Option<&'a st .chain(std::iter::once((word.len(), 0))) { let segment = word.get(segment_start..segment_end)?; - // Normalize punctuation surrounding a scheme segment while retaining - // internal punctuation, so prose such as "Bearer-based" stays valid. - let normalized_scheme = segment.trim_matches(is_authorization_separator); + // 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) { - if delimiter_len == 0 { - return Some(""); - } - return word - .get(segment_end + delimiter_len..) - .map(|value| value.trim_start_matches(is_authorization_colon)); + let has_colon = delimiter_len != 0; + let standalone_wrapped = !has_colon + && 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; } @@ -1386,7 +1431,7 @@ fn authorization_value_in_word<'a>(word: &'a str, scheme: &str) -> Option<&'a st } fn is_credential_like_authorization_value(candidate: &str) -> bool { - let prose_word = candidate.trim_matches(is_authorization_separator); + let prose_word = candidate.trim_matches(is_authorization_boundary); !prose_word.is_empty() && ![ "access", @@ -1407,40 +1452,15 @@ fn is_credential_like_authorization_value(candidate: &str) -> bool { } fn is_authorization_separator_token(candidate: &str) -> bool { - !candidate.is_empty() && candidate.chars().all(is_authorization_separator) + !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_separator(character: char) -> bool { - character.is_ascii_punctuation() - || matches!( - character, - // Common quotation marks copied from rich-text systems. - '\u{00ab}' - | '\u{00bb}' - | '\u{2018}'..='\u{201f}' - | '\u{2039}' - | '\u{203a}' - // Common standalone separators. - | '\u{2010}'..='\u{2015}' - | '\u{2022}' - | '\u{2026}' - // CJK quotation and bracket marks. - | '\u{3008}'..='\u{3011}' - | '\u{3014}'..='\u{301b}' - // Small and full-width forms used around copied header syntax. - | '\u{fe55}' - | '\u{ff08}' - | '\u{ff09}' - | '\u{ff1a}' - | '\u{ff3b}' - | '\u{ff3d}' - | '\u{ff5b}' - | '\u{ff5d}' - ) +fn is_authorization_boundary(character: char) -> bool { + !character.is_alphanumeric() && !character.is_whitespace() && !character.is_control() } #[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] @@ -3997,6 +4017,22 @@ mod tests { "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, + ), ( "Bearer : abcdef", DeploymentWaiverMetadataError::SummaryCredentialLiteral, @@ -4083,6 +4119,9 @@ mod tests { "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", ] { validate_deployment_waiver_metadata("OPS-42", Some(summary)) .unwrap_or_else(|error| panic!("ordinary summary rejected: {summary:?}: {error}")); From 9be46dcb9e2ad5d3a351be8daf81094bf7932297 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 23 Jul 2026 23:41:19 +0700 Subject: [PATCH 34/58] fix(platform): preserve wrapped waiver prose Signed-off-by: Jeremi Joslin --- crates/registry-platform-ops/src/lib.rs | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/crates/registry-platform-ops/src/lib.rs b/crates/registry-platform-ops/src/lib.rs index 4b8b15422..7cc5d9401 100644 --- a/crates/registry-platform-ops/src/lib.rs +++ b/crates/registry-platform-ops/src/lib.rs @@ -1409,8 +1409,7 @@ fn authorization_value_in_word<'a>(word: &'a str, scheme: &str) -> Option Date: Thu, 23 Jul 2026 23:59:40 +0700 Subject: [PATCH 35/58] fix(registryctl): require expires-at response field Signed-off-by: Jeremi Joslin --- .../src/project_authoring/commands.rs | 5 ++ .../src/project_authoring/tests.rs | 47 +++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/crates/registryctl/src/project_authoring/commands.rs b/crates/registryctl/src/project_authoring/commands.rs index ee33eec54..ad6dbdcf1 100644 --- a/crates/registryctl/src/project_authoring/commands.rs +++ b/crates/registryctl/src/project_authoring/commands.rs @@ -721,6 +721,11 @@ fn validate_live_response( let result_object = result .as_object() .ok_or_else(|| anyhow!("governed Notary result must be an object"))?; + if !result_object.contains_key("expires_at") { + bail!( + "governed Notary result does not match the closed public claim-result schema: expires_at is required" + ); + } let result_view: registry_notary_core::ClaimResultView = serde_json::from_value(result.clone()).map_err(|_| { anyhow!( diff --git a/crates/registryctl/src/project_authoring/tests.rs b/crates/registryctl/src/project_authoring/tests.rs index 0260e640d..17ae9eb2d 100644 --- a/crates/registryctl/src/project_authoring/tests.rs +++ b/crates/registryctl/src/project_authoring/tests.rs @@ -735,6 +735,53 @@ outputs: ); } + #[test] + fn governed_live_result_requires_explicit_expires_at() { + let claims = vec!["eligible".to_string()]; + 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, &claims, &expected) + .expect("explicit null expires_at passes"), + 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, &claims, &expected) + .expect_err("missing expires_at must fail closed") + .to_string() + .contains("expires_at is required") + ); + } + #[test] fn governed_live_result_rejects_partial_disclosure_expectations() { let claims = vec!["eligible".to_string()]; From c429e2edbf5a1eb8ae113c2ff689c404de0ee446 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Fri, 24 Jul 2026 00:03:39 +0700 Subject: [PATCH 36/58] fix(release): bind upgrade image lock evidence Signed-off-by: Jeremi Joslin --- release/exercises/README.md | 7 + .../scripts/test_validate_upgrade_exercise.py | 165 ++++++++++++++++-- release/scripts/validate-upgrade-exercise.py | 75 ++++++++ 3 files changed, 230 insertions(+), 17 deletions(-) diff --git a/release/exercises/README.md b/release/exercises/README.md index 2a66dcff5..ad1e30fda 100644 --- a/release/exercises/README.md +++ b/release/exercises/README.md @@ -38,6 +38,11 @@ release are frozen and independently verified: 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 `:`). + Keep the downloaded `registryctl--image-lock.json` beside + its `SHA256SUMS`, signed release capsule, Cosign signatures and + certificates, and shared SLSA provenance. The validator authenticates that + exact release asset and requires its byte digest and image pins to match the + record. 6. Exercise every required check against the pinned standalone Solmara topology. Record `passed` only when the retained evidence proves the check. Honest `failed` and `not_run` records remain structurally valid; a `not_run` @@ -47,8 +52,10 @@ release are frozen and independently verified: ```sh python3 release/scripts/validate-upgrade-exercise.py \ + --candidate-asset-dir /private/path/candidate-release-assets \ release/exercises/ python3 release/scripts/validate-upgrade-exercise.py --require-pass \ + --candidate-asset-dir /private/path/candidate-release-assets \ release/exercises/ ``` diff --git a/release/scripts/test_validate_upgrade_exercise.py b/release/scripts/test_validate_upgrade_exercise.py index 4031ed277..f4b2fc13c 100644 --- a/release/scripts/test_validate_upgrade_exercise.py +++ b/release/scripts/test_validate_upgrade_exercise.py @@ -5,6 +5,7 @@ import importlib.util import io import json +import os import sys import tempfile import unittest @@ -33,6 +34,32 @@ 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_dir = Path("/authenticated-candidate-assets") + self.real_load_candidate = self.module.load_candidate + self.load_candidate = mock.patch.object( + self.module, + "load_candidate", + return_value={ + "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.start() + self.addCleanup(self.load_candidate.stop) + + def validate_record(self, data, **kwargs): + kwargs.setdefault("candidate_asset_dir", self.candidate_asset_dir) + return self.module.validate_record(data, **kwargs) def candidate(self): def replace(value): @@ -95,14 +122,19 @@ def replace(value): 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.module.load_candidate.reset_mock() + self.validate_record( self.candidate(), allow_template=False, require_all_passed=True ) + self.module.load_candidate.assert_called_once_with( + ROOT / TARGET_MANIFEST, + self.candidate_asset_dir / "registryctl-v0.12.2-image-lock.json", + ) def test_candidate_tag_must_resolve_to_exact_target_commit(self) -> None: record = self.candidate() @@ -124,7 +156,7 @@ def mismatched_tag(arguments, **kwargs): self.module.ExerciseError, "release tag v0.12.2 does not resolve to target_release.source_commit", ): - self.module.validate_record(record, allow_template=False) + self.validate_record(record, allow_template=False) git_run.assert_any_call( ["git", "rev-parse", "--verify", "refs/tags/v0.12.2^{commit}"], cwd=ROOT, @@ -137,7 +169,7 @@ 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_candidate_release_identifiers_are_strict(self) -> None: for field, value in ( @@ -149,19 +181,19 @@ 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_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_failed_and_not_run_results_are_recordable_but_fail_promotion(self) -> None: record = self.candidate() @@ -169,9 +201,9 @@ def test_failed_and_not_run_results_are_recordable_but_fail_promotion(self) -> N record["results"][1].update( {"outcome": "not_run", "observed_at": None, "evidence_label": None, "evidence_sha256": None} ) - self.module.validate_record(record, allow_template=False) + self.validate_record(record, allow_template=False) with self.assertRaisesRegex(self.module.ExerciseError, "--require-pass"): - self.module.validate_record( + self.validate_record( record, allow_template=False, require_all_passed=True ) @@ -197,7 +229,13 @@ def test_discovery_requires_candidate_passes_and_preserves_templates(self) -> No with mock.patch.object( sys, "argv", - [str(SCRIPT), "--discover", str(records)], + [ + str(SCRIPT), + "--discover", + str(records), + "--candidate-asset-dir", + str(self.candidate_asset_dir), + ], ), mock.patch("sys.stderr", new=stderr): self.assertEqual(self.module.main(), 1) self.assertIn( @@ -217,7 +255,7 @@ def test_promotion_rejects_product_oci_layout_drift(self) -> None: self.module.ExerciseError, f"--require-pass rejects P/T {product} OCI layout drift", ): - self.module.validate_record( + self.validate_record( record, allow_template=False, require_all_passed=True ) @@ -225,7 +263,7 @@ 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() @@ -264,7 +302,7 @@ def test_manifest_hash_ref_and_artifact_set_drift_are_rejected(self) -> None: record["candidate_artifact_set"]["artifacts"] ) with self.assertRaisesRegex(self.module.ExerciseError, "does not match exact target"): - self.module.validate_record(record, allow_template=False) + 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"] = ( @@ -274,11 +312,104 @@ def test_manifest_hash_ref_and_artifact_set_drift_are_rejected(self) -> None: record["candidate_artifact_set"]["artifacts"] ) with self.assertRaisesRegex(self.module.ExerciseError, "identity does not match"): - self.module.validate_record(record, allow_template=False) + 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.module.validate_record(record, allow_template=False) + 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_dir = Path(temporary) + 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) + ) + candidate = self.module.load_candidate.return_value.copy() + + def authenticated_candidate(_manifest_path, lock_path): + metadata = candidate.copy() + 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_dir=asset_dir, + ) + image_lock.write_bytes(image_lock.read_bytes() + b"\n") + with self.assertRaisesRegex( + self.module.ExerciseError, + "exact authenticated release image-lock", + ): + self.validate_record( + record, + allow_template=False, + candidate_asset_dir=asset_dir, + ) + + def test_candidate_evidence_requires_release_asset_directory(self) -> None: + with self.assertRaisesRegex( + self.module.ExerciseError, "--candidate-asset-dir" + ): + self.module.validate_record( + self.candidate(), + allow_template=False, + candidate_asset_dir=None, + ) + + def test_real_v0122_release_image_lock_authenticates(self) -> None: + asset_dir_value = os.environ.get("REGISTRY_TEST_V0122_ASSET_DIR") + if not asset_dir_value: + self.skipTest( + "REGISTRY_TEST_V0122_ASSET_DIR is required for real release assets" + ) + asset_dir = Path(asset_dir_value) + image_lock = asset_dir / "registryctl-v0.12.2-image-lock.json" + lock = json.loads(image_lock.read_text(encoding="utf-8")) + record = self.candidate() + artifacts = record["candidate_artifact_set"]["artifacts"] + record["target_release"]["relay_image_digest"] = lock["images"][ + "registry-relay" + ].split("@", 1)[1] + record["target_release"]["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" + ] + 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_dir=asset_dir, + ) def test_topology_requires_one_dedicated_notary_per_relay(self) -> None: record = self.candidate() @@ -287,7 +418,7 @@ def test_topology_requires_one_dedicated_notary_per_relay(self) -> None: {"relay": "relay-authority-b", "notary": "notary-authority-a"} ) with self.assertRaisesRegex(self.module.ExerciseError, "dedicated Notary"): - self.module.validate_record(record, allow_template=False) + 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 af0684fcb..d4fb0c035 100644 --- a/release/scripts/validate-upgrade-exercise.py +++ b/release/scripts/validate-upgrade-exercise.py @@ -16,6 +16,13 @@ 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" @@ -435,6 +442,62 @@ def validate_target_binding(record: dict[str, Any], root: Path) -> None: raise ExerciseError(f"candidate_artifact_set.artifacts.{field} does not match exact Git object") +def validate_image_lock_binding( + record: dict[str, Any], + root: Path, + candidate_asset_dir: Path | None, +) -> None: + if candidate_asset_dir is None: + raise ExerciseError( + "candidate evidence requires --candidate-asset-dir for image-lock authentication" + ) + target = record["target_release"] + image_lock_path = ( + candidate_asset_dir.expanduser() + / f"registryctl-{target['version']}-image-lock.json" + ) + manifest_path = root / record["target_release_manifest"]["path"] + try: + candidate = load_candidate(manifest_path, image_lock_path) + except (CandidateError, OSError): + raise ExerciseError( + "candidate release image lock could not be authenticated" + ) from None + artifacts = record["candidate_artifact_set"]["artifacts"] + expected = { + "release_id": target["release_id"], + "version": target["version"].removeprefix("v"), + "source_ref": target["source_ref"], + "source_tag": target["version"], + "tag_target": target["source_commit"], + "image_lock_sha256": artifacts["image_lock"], + "relay_image": "ghcr.io/registrystack/registry-relay@sha256:" + + target["relay_image_digest"].removeprefix("sha256:"), + "notary_image": "ghcr.io/registrystack/registry-notary@sha256:" + + target["notary_image_digest"].removeprefix("sha256:"), + } + if candidate["image_lock_sha256"].removeprefix( + "sha256:" + ) != expected["image_lock_sha256"].removeprefix("sha256:"): + raise ExerciseError( + "candidate_artifact_set.artifacts.image_lock does not match " + "the exact authenticated release image-lock asset" + ) + for field in ( + "release_id", + "version", + "source_ref", + "source_tag", + "tag_target", + "relay_image", + "notary_image", + ): + if candidate[field] != expected[field]: + raise ExerciseError( + "authenticated release image lock does not match target release coordinates" + ) + + def require_pass(record: dict[str, Any]) -> None: if not record["candidate_frozen"] or not record["candidate_independently_verified"]: raise ExerciseError("--require-pass requires both candidate attestations") @@ -457,6 +520,7 @@ def validate_record( allow_template: bool, require_all_passed: bool = False, root: Path = ROOT, + candidate_asset_dir: Path | None = None, ) -> None: record = require_object( data, @@ -519,6 +583,7 @@ def validate_record( validate_results(record["results"], template=template) if not template: validate_target_binding(record, root) + validate_image_lock_binding(record, root, candidate_asset_dir) if require_all_passed: require_pass(record) @@ -533,6 +598,14 @@ def main() -> int: ) parser.add_argument("--require-pass", action="store_true") parser.add_argument("--discover", type=Path) + parser.add_argument( + "--candidate-asset-dir", + type=Path, + help=( + "directory containing the downloaded image lock, SHA256SUMS, " + "release capsule, signatures, certificates, and provenance" + ), + ) args = parser.parse_args() try: if args.discover: @@ -546,6 +619,7 @@ def main() -> int: data, allow_template=template, require_all_passed=not template, + candidate_asset_dir=args.candidate_asset_dir, ) print(f"upgrade exercise discovery passed: {len(records)} record(s)") return 0 @@ -556,6 +630,7 @@ def main() -> int: data, allow_template=args.template, require_all_passed=args.require_pass, + candidate_asset_dir=args.candidate_asset_dir, ) except (ExerciseError, OSError, json.JSONDecodeError) as error: print(f"upgrade exercise validation failed: {error}", file=sys.stderr) From e6dedcdefde1c0e15b2a77dc87abae59534bc968 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Fri, 24 Jul 2026 00:12:50 +0700 Subject: [PATCH 37/58] docs: clarify the Solmara reader gate Signed-off-by: Jeremi Joslin --- .../docs/tutorials/first-run-with-solmara-lab.mdx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) 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..501ff9f33 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,11 @@ 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 pinned atomically to that candidate. +{/* 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 From ff4664717c3d6973ac0a96077cc2d8d96d039162 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Fri, 24 Jul 2026 00:13:25 +0700 Subject: [PATCH 38/58] fix(registryctl): validate provenance constants Signed-off-by: Jeremi Joslin --- .../src/project_authoring/commands.rs | 9 +++ .../src/project_authoring/tests.rs | 64 +++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/crates/registryctl/src/project_authoring/commands.rs b/crates/registryctl/src/project_authoring/commands.rs index ad6dbdcf1..67d82bd93 100644 --- a/crates/registryctl/src/project_authoring/commands.rs +++ b/crates/registryctl/src/project_authoring/commands.rs @@ -732,6 +732,15 @@ fn validate_live_response( "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" + ); + } if !result_view.provenance.derived_from.is_empty() { bail!("governed Notary result provenance derived_from must remain empty"); } diff --git a/crates/registryctl/src/project_authoring/tests.rs b/crates/registryctl/src/project_authoring/tests.rs index 17ae9eb2d..a060b4614 100644 --- a/crates/registryctl/src/project_authoring/tests.rs +++ b/crates/registryctl/src/project_authoring/tests.rs @@ -782,6 +782,70 @@ outputs: ); } + #[test] + fn governed_live_result_requires_canonical_provenance_constants() { + let claims = vec!["eligible".to_string()]; + 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, &claims, &expected) + .expect("canonical provenance constants pass"), + 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, &claims, &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_rejects_partial_disclosure_expectations() { let claims = vec!["eligible".to_string()]; From 99ebd8bd3468a5d228e9490e014b6e737d0badde Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Fri, 24 Jul 2026 00:13:41 +0700 Subject: [PATCH 39/58] docs(release): add integration pilot report contract Signed-off-by: Jeremi Joslin --- release/conformance/integrations/README.md | 6 ++ .../integrations/pilot-report.template.md | 78 +++++++++++++++++++ release/scripts/test_integration_e2_runner.py | 39 ++++++++++ 3 files changed, 123 insertions(+) create mode 100644 release/conformance/integrations/pilot-report.template.md diff --git a/release/conformance/integrations/README.md b/release/conformance/integrations/README.md index 71584a0bd..344a97339 100644 --- a/release/conformance/integrations/README.md +++ b/release/conformance/integrations/README.md @@ -163,6 +163,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..bc891da36 --- /dev/null +++ b/release/conformance/integrations/pilot-report.template.md @@ -0,0 +1,78 @@ +# 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] +- Overall outcome: [passed, failed, or incomplete] + +Issue closure still requires a frozen published candidate, an independent +operator, and an owner-approved source. 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. Reiterate any unexercised area +that affects the conclusion.] diff --git a/release/scripts/test_integration_e2_runner.py b/release/scripts/test_integration_e2_runner.py index 77061386a..c454e0a99 100644 --- a/release/scripts/test_integration_e2_runner.py +++ b/release/scripts/test_integration_e2_runner.py @@ -268,6 +268,45 @@ 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) + 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:", + "### 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 From 121be1b100dc6da055d8b50ae490bd842840ee1c Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Fri, 24 Jul 2026 00:18:19 +0700 Subject: [PATCH 40/58] chore: narrow adopter exercise packet scope Signed-off-by: Jeremi Joslin --- .../tutorials/first-run-with-solmara-lab.mdx | 9 +-- release/conformance/integrations/README.md | 6 -- .../integrations/pilot-report.template.md | 78 ------------------- release/scripts/test_integration_e2_runner.py | 39 ---------- 4 files changed, 4 insertions(+), 128 deletions(-) delete mode 100644 release/conformance/integrations/pilot-report.template.md 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 501ff9f33..ad0ec17e9 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,11 +38,10 @@ 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 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 pinned atomically to that candidate. -{/* 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. */} +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. ## Prerequisites diff --git a/release/conformance/integrations/README.md b/release/conformance/integrations/README.md index 344a97339..71584a0bd 100644 --- a/release/conformance/integrations/README.md +++ b/release/conformance/integrations/README.md @@ -163,12 +163,6 @@ 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 deleted file mode 100644 index bc891da36..000000000 --- a/release/conformance/integrations/pilot-report.template.md +++ /dev/null @@ -1,78 +0,0 @@ -# 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] -- Overall outcome: [passed, failed, or incomplete] - -Issue closure still requires a frozen published candidate, an independent -operator, and an owner-approved source. 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. Reiterate any unexercised area -that affects the conclusion.] diff --git a/release/scripts/test_integration_e2_runner.py b/release/scripts/test_integration_e2_runner.py index c454e0a99..77061386a 100644 --- a/release/scripts/test_integration_e2_runner.py +++ b/release/scripts/test_integration_e2_runner.py @@ -268,45 +268,6 @@ 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) - 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:", - "### 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 From b3797021078bfa05edd0ec45806deddb368fa872 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Fri, 24 Jul 2026 00:19:42 +0700 Subject: [PATCH 41/58] fix(release): prepare versioned upgrade assets Signed-off-by: Jeremi Joslin --- .github/workflows/ci.yml | 36 ++++- release/exercises/README.md | 18 ++- release/scripts/check-gates-inventory.py | 14 +- .../prepare-upgrade-exercise-assets.py | 152 ++++++++++++++++++ release/scripts/test_check_gates_inventory.py | 19 +++ .../test_prepare_upgrade_exercise_assets.py | 147 +++++++++++++++++ .../scripts/test_validate_upgrade_exercise.py | 57 +++++-- release/scripts/validate-upgrade-exercise.py | 24 +-- 8 files changed, 431 insertions(+), 36 deletions(-) create mode 100644 release/scripts/prepare-upgrade-exercise-assets.py create mode 100644 release/scripts/test_prepare_upgrade_exercise_assets.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2eff28ceb..487a688e2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -558,8 +558,42 @@ jobs: - name: Test upgrade exercise validator run: python3 -m unittest release/scripts/test_validate_upgrade_exercise.py + - 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/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 + run: python3 release/scripts/validate-upgrade-exercise.py --discover release/exercises --candidate-asset-root target/upgrade-exercise-assets - name: Stable surface compatibility env: diff --git a/release/exercises/README.md b/release/exercises/README.md index ad1e30fda..a7e61351f 100644 --- a/release/exercises/README.md +++ b/release/exercises/README.md @@ -38,11 +38,12 @@ release are frozen and independently verified: 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 `:`). - Keep the downloaded `registryctl--image-lock.json` beside - its `SHA256SUMS`, signed release capsule, Cosign signatures and - certificates, and shared SLSA provenance. The validator authenticates that - exact release asset and requires its byte digest and image pins to match the - record. + Under a private candidate-asset root, keep one directory named for each + 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 each exact release asset and + requires its byte digest and image pins to match the corresponding record. 6. Exercise every required check against the pinned standalone Solmara topology. Record `passed` only when the retained evidence proves the check. Honest `failed` and `not_run` records remain structurally valid; a `not_run` @@ -51,11 +52,14 @@ release are frozen and independently verified: 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-dir /private/path/candidate-release-assets \ + --candidate-asset-root /private/path/candidate-release-assets \ release/exercises/ python3 release/scripts/validate-upgrade-exercise.py --require-pass \ - --candidate-asset-dir /private/path/candidate-release-assets \ + --candidate-asset-root /private/path/candidate-release-assets \ release/exercises/ ``` diff --git a/release/scripts/check-gates-inventory.py b/release/scripts/check-gates-inventory.py index e24644b68..0d7f389bc 100644 --- a/release/scripts/check-gates-inventory.py +++ b/release/scripts/check-gates-inventory.py @@ -181,9 +181,21 @@ "Upgrade exercise validator tests", "run: python3 -m unittest release/scripts/test_validate_upgrade_exercise.py", ), + ( + "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", + "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/prepare-upgrade-exercise-assets.py b/release/scripts/prepare-upgrade-exercise-assets.py new file mode 100644 index 000000000..c6b588096 --- /dev/null +++ b/release/scripts/prepare-upgrade-exercise-assets.py @@ -0,0 +1,152 @@ +#!/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" +VERSION = re.compile(r"^v[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$") + + +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") + target = value.get("target_release") + version = target.get("version") if isinstance(target, dict) else None + if not isinstance(version, str) or VERSION.fullmatch(version) is None: + raise PreparationError( + "candidate upgrade target 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/test_check_gates_inventory.py b/release/scripts/test_check_gates_inventory.py index 6ea5316ea..89bf5700e 100644 --- a/release/scripts/test_check_gates_inventory.py +++ b/release/scripts/test_check_gates_inventory.py @@ -376,6 +376,25 @@ def test_missing_upgrade_exercise_record_discovery_is_reported(self) -> None: "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( + "--candidate-asset-root target/upgrade-exercise-assets", + "--candidate-asset-root target/unauthenticated-assets", + ) + self.assertIn( + "Upgrade exercise record discovery", self.module.missing_gates(text) + ) + def test_missing_stable_error_registry_path_filter_is_reported(self) -> None: text = self.workflow.replace( "docs/site/src/content/docs/reference/errors.mdx)", 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..278fe7b68 --- /dev/null +++ b/release/scripts/test_prepare_upgrade_exercise_assets.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +import importlib.util +import json +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + + +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) -> None: + (directory / name).write_text( + json.dumps( + { + "record_kind": "candidate_evidence", + "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 = 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.12.2",), versions) + self.assertEqual( + set(self.module.required_asset_names("v0.12.2")), + { + path.name + for path in (root / "assets" / "v0.12.2").iterdir() + }, + ) + self.assertEqual("v0.12.2", commands[0][3]) + + 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.11.0") + 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.2"), versions) + self.assertTrue((root / "assets" / "v0.11.0").is_dir()) + self.assertTrue((root / "assets" / "v0.12.2").is_dir()) + + 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 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_validate_upgrade_exercise.py b/release/scripts/test_validate_upgrade_exercise.py index f4b2fc13c..3b9813650 100644 --- a/release/scripts/test_validate_upgrade_exercise.py +++ b/release/scripts/test_validate_upgrade_exercise.py @@ -34,7 +34,7 @@ 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_dir = Path("/authenticated-candidate-assets") + self.candidate_asset_root = Path("/authenticated-candidate-assets") self.real_load_candidate = self.module.load_candidate self.load_candidate = mock.patch.object( self.module, @@ -58,7 +58,7 @@ def setUp(self) -> None: self.addCleanup(self.load_candidate.stop) def validate_record(self, data, **kwargs): - kwargs.setdefault("candidate_asset_dir", self.candidate_asset_dir) + kwargs.setdefault("candidate_asset_root", self.candidate_asset_root) return self.module.validate_record(data, **kwargs) def candidate(self): @@ -133,7 +133,11 @@ def test_complete_candidate_record_is_valid(self) -> None: ) self.module.load_candidate.assert_called_once_with( ROOT / TARGET_MANIFEST, - self.candidate_asset_dir / "registryctl-v0.12.2-image-lock.json", + ( + self.candidate_asset_root + / "v0.12.2" + / "registryctl-v0.12.2-image-lock.json" + ), ) def test_candidate_tag_must_resolve_to_exact_target_commit(self) -> None: @@ -233,8 +237,8 @@ def test_discovery_requires_candidate_passes_and_preserves_templates(self) -> No str(SCRIPT), "--discover", str(records), - "--candidate-asset-dir", - str(self.candidate_asset_dir), + "--candidate-asset-root", + str(self.candidate_asset_root), ], ), mock.patch("sys.stderr", new=stderr): self.assertEqual(self.module.main(), 1) @@ -322,7 +326,9 @@ def test_candidate_image_lock_digest_matches_authenticated_asset_bytes( self, ) -> None: with tempfile.TemporaryDirectory() as temporary: - asset_dir = Path(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() @@ -350,7 +356,7 @@ def authenticated_candidate(_manifest_path, lock_path): self.validate_record( record, allow_template=False, - candidate_asset_dir=asset_dir, + candidate_asset_root=asset_root, ) image_lock.write_bytes(image_lock.read_bytes() + b"\n") with self.assertRaisesRegex( @@ -360,26 +366,47 @@ def authenticated_candidate(_manifest_path, lock_path): self.validate_record( record, allow_template=False, - candidate_asset_dir=asset_dir, + candidate_asset_root=asset_root, ) def test_candidate_evidence_requires_release_asset_directory(self) -> None: with self.assertRaisesRegex( - self.module.ExerciseError, "--candidate-asset-dir" + self.module.ExerciseError, "--candidate-asset-root" ): self.module.validate_record( self.candidate(), allow_template=False, - candidate_asset_dir=None, + 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_real_v0122_release_image_lock_authenticates(self) -> None: - asset_dir_value = os.environ.get("REGISTRY_TEST_V0122_ASSET_DIR") - if not asset_dir_value: + asset_root_value = os.environ.get("REGISTRY_TEST_UPGRADE_ASSET_ROOT") + if not asset_root_value: self.skipTest( - "REGISTRY_TEST_V0122_ASSET_DIR is required for real release assets" + "REGISTRY_TEST_UPGRADE_ASSET_ROOT is required for real release assets" ) - asset_dir = Path(asset_dir_value) + asset_root = Path(asset_root_value) + asset_dir = asset_root / "v0.12.2" image_lock = asset_dir / "registryctl-v0.12.2-image-lock.json" lock = json.loads(image_lock.read_text(encoding="utf-8")) record = self.candidate() @@ -408,7 +435,7 @@ def test_real_v0122_release_image_lock_authenticates(self) -> None: record, allow_template=False, require_all_passed=True, - candidate_asset_dir=asset_dir, + candidate_asset_root=asset_root, ) def test_topology_requires_one_dedicated_notary_per_relay(self) -> None: diff --git a/release/scripts/validate-upgrade-exercise.py b/release/scripts/validate-upgrade-exercise.py index d4fb0c035..c2141bac6 100644 --- a/release/scripts/validate-upgrade-exercise.py +++ b/release/scripts/validate-upgrade-exercise.py @@ -445,16 +445,16 @@ def validate_target_binding(record: dict[str, Any], root: Path) -> None: def validate_image_lock_binding( record: dict[str, Any], root: Path, - candidate_asset_dir: Path | None, + candidate_asset_root: Path | None, ) -> None: - if candidate_asset_dir is None: + if candidate_asset_root is None: raise ExerciseError( - "candidate evidence requires --candidate-asset-dir for image-lock authentication" + "candidate evidence requires --candidate-asset-root for image-lock authentication" ) target = record["target_release"] + candidate_asset_dir = candidate_asset_root.expanduser() / target["version"] image_lock_path = ( - candidate_asset_dir.expanduser() - / f"registryctl-{target['version']}-image-lock.json" + candidate_asset_dir / f"registryctl-{target['version']}-image-lock.json" ) manifest_path = root / record["target_release_manifest"]["path"] try: @@ -520,7 +520,7 @@ def validate_record( allow_template: bool, require_all_passed: bool = False, root: Path = ROOT, - candidate_asset_dir: Path | None = None, + candidate_asset_root: Path | None = None, ) -> None: record = require_object( data, @@ -583,7 +583,7 @@ def validate_record( validate_results(record["results"], template=template) if not template: validate_target_binding(record, root) - validate_image_lock_binding(record, root, candidate_asset_dir) + validate_image_lock_binding(record, root, candidate_asset_root) if require_all_passed: require_pass(record) @@ -599,11 +599,11 @@ def main() -> int: parser.add_argument("--require-pass", action="store_true") parser.add_argument("--discover", type=Path) parser.add_argument( - "--candidate-asset-dir", + "--candidate-asset-root", type=Path, help=( - "directory containing the downloaded image lock, SHA256SUMS, " - "release capsule, signatures, certificates, and provenance" + "root containing one downloaded and authenticated asset directory " + "per target version" ), ) args = parser.parse_args() @@ -619,7 +619,7 @@ def main() -> int: data, allow_template=template, require_all_passed=not template, - candidate_asset_dir=args.candidate_asset_dir, + candidate_asset_root=args.candidate_asset_root, ) print(f"upgrade exercise discovery passed: {len(records)} record(s)") return 0 @@ -630,7 +630,7 @@ def main() -> int: data, allow_template=args.template, require_all_passed=args.require_pass, - candidate_asset_dir=args.candidate_asset_dir, + candidate_asset_root=args.candidate_asset_root, ) except (ExerciseError, OSError, json.JSONDecodeError) as error: print(f"upgrade exercise validation failed: {error}", file=sys.stderr) From 0a3e1ad06d93964a42853912637f58d455fb8ba5 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Fri, 24 Jul 2026 00:21:26 +0700 Subject: [PATCH 42/58] docs(release): require maintainer evidence comparison Signed-off-by: Jeremi Joslin --- .../integrations/pilot-report.template.md | 12 +++++++++--- release/scripts/test_integration_e2_runner.py | 14 ++++++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/release/conformance/integrations/pilot-report.template.md b/release/conformance/integrations/pilot-report.template.md index bc891da36..7af8719e6 100644 --- a/release/conformance/integrations/pilot-report.template.md +++ b/release/conformance/integrations/pilot-report.template.md @@ -23,10 +23,15 @@ evidence. One completed pilot is not proof of broad production readiness. - 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, and an owner-approved source. A plan or maintainer-run substitute +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 @@ -74,5 +79,6 @@ Do not use this section to waive a blocker silently. ## Conclusion [State whether the bounded pilot closes the external-pilot gate, remains -blocked, or requires a narrower support claim. Reiterate any unexercised area -that affects the conclusion.] +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/scripts/test_integration_e2_runner.py b/release/scripts/test_integration_e2_runner.py index c454e0a99..6df841e45 100644 --- a/release/scripts/test_integration_e2_runner.py +++ b/release/scripts/test_integration_e2_runner.py @@ -287,6 +287,18 @@ def test_pilot_report_template_preserves_the_public_contract(self) -> None: 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", @@ -294,6 +306,8 @@ def test_pilot_report_template_preserves_the_public_contract(self) -> None: "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", From bcd5b97dc4fcb098ecce6ee85279bd3e7fd8294c Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Fri, 24 Jul 2026 00:27:43 +0700 Subject: [PATCH 43/58] docs: complete the Solmara reader criteria Signed-off-by: Jeremi Joslin --- .../content/docs/tutorials/first-run-with-solmara-lab.mdx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 501ff9f33..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 @@ -40,7 +40,10 @@ For a standalone local project generated from your own data, use the `registryct [evaluate a claim with Registry Notary](../verify-claim-registry-api/). Clone Solmara Lab when you 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 pinned atomically to that candidate. +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. */} From e347da91f8785d949627aa177238bef619d25483 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Fri, 24 Jul 2026 00:34:47 +0700 Subject: [PATCH 44/58] fix(platform): align waiver schema metadata Signed-off-by: Jeremi Joslin --- .../registry-notary-core/src/config/schema.rs | 30 +++ crates/registry-notary-core/src/deployment.rs | 4 +- crates/registry-notary/tests/config_schema.rs | 108 ++++++++++ .../registry.ops.posture.v1.schema.json | 51 +++-- crates/registry-platform-ops/src/lib.rs | 50 +++++ .../tests/posture_profile_gates_contract.rs | 189 +++++++++++++++++- crates/registry-relay/src/config/mod.rs | 4 +- crates/registry-relay/src/config/schema.rs | 30 +++ crates/registry-relay/tests/config_schema.rs | 108 ++++++++++ .../src/content/docs/spec/rs-op-posture.mdx | 13 +- products/platform/CHANGELOG.md | 5 +- schemas/registry-notary.config.schema.json | 42 +++- schemas/registry-relay.config.schema.json | 42 +++- 13 files changed, 637 insertions(+), 39 deletions(-) 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/deployment.rs b/crates/registry-notary-core/src/deployment.rs index e2ba09c04..266923401 100644 --- a/crates/registry-notary-core/src/deployment.rs +++ b/crates/registry-notary-core/src/deployment.rs @@ -170,10 +170,10 @@ impl DeploymentEvidenceConfig { #[schemars(!from)] pub struct DeploymentWaiverConfig { pub finding: String, - #[schemars(length(min = 1, max = 128), pattern(r"^(?!.*\.\.)[A-Za-z0-9._:-]+$"))] + #[schemars(with = "crate::config::schema::DeploymentWaiverReferenceSchema")] pub reference: String, #[serde(default, skip_serializing_if = "Option::is_none")] - #[schemars(with = "String", length(min = 1, max = 256))] + #[schemars(with = "crate::config::schema::DeploymentWaiverSummarySchema")] pub summary: Option, pub expires: String, } diff --git a/crates/registry-notary/tests/config_schema.rs b/crates/registry-notary/tests/config_schema.rs index 899c1bcc7..4125d9367 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"; @@ -96,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", @@ -336,6 +357,93 @@ fn deployment_waiver_schema_rejects_retired_and_noncanonical_metadata() { 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-----"), + ] { + 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/schemas/registry.ops.posture.v1.schema.json b/crates/registry-platform-ops/schemas/registry.ops.posture.v1.schema.json index 860ade953..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, @@ -432,15 +465,10 @@ ], "properties": { "reference": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "pattern": "^(?!.*\\.\\.)[A-Za-z0-9._:-]+$" + "$ref": "#/$defs/waiver_reference" }, "summary": { - "type": "string", - "minLength": 1, - "maxLength": 256 + "$ref": "#/$defs/waiver_summary" }, "expires": { "type": "string", @@ -462,15 +490,10 @@ "$ref": "#/$defs/finding_id" }, "reference": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "pattern": "^(?!.*\\.\\.)[A-Za-z0-9._:-]+$" + "$ref": "#/$defs/waiver_reference" }, "summary": { - "type": "string", - "minLength": 1, - "maxLength": 256 + "$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 7cc5d9401..5188e097b 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"); 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 f6f0b0f8b..ab00448a5 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; @@ -85,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(); @@ -94,6 +108,173 @@ 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-----"), + ] { + 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(); diff --git a/crates/registry-relay/src/config/mod.rs b/crates/registry-relay/src/config/mod.rs index 008713e46..ed94f11d9 100644 --- a/crates/registry-relay/src/config/mod.rs +++ b/crates/registry-relay/src/config/mod.rs @@ -124,10 +124,10 @@ pub struct DeploymentConfig { #[schemars(!from)] pub struct DeploymentWaiverConfig { pub finding: String, - #[schemars(length(min = 1, max = 128), pattern(r"^(?!.*\.\.)[A-Za-z0-9._:-]+$"))] + #[schemars(with = "schema::DeploymentWaiverReferenceSchema")] pub reference: String, #[serde(default, skip_serializing_if = "Option::is_none")] - #[schemars(with = "String", length(min = 1, max = 256))] + #[schemars(with = "schema::DeploymentWaiverSummarySchema")] pub summary: Option, pub expires: String, } 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/tests/config_schema.rs b/crates/registry-relay/tests/config_schema.rs index 4bd09caf0..30ac7569a 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}; @@ -87,6 +91,23 @@ 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")) } @@ -291,6 +312,93 @@ fn deployment_waiver_schema_rejects_retired_and_noncanonical_metadata() { 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-----"), + ] { + 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/docs/site/src/content/docs/spec/rs-op-posture.mdx b/docs/site/src/content/docs/spec/rs-op-posture.mdx index 3e90c7c44..85a68653b 100644 --- a/docs/site/src/content/docs/spec/rs-op-posture.mdx +++ b/docs/site/src/content/docs/spec/rs-op-posture.mdx @@ -33,8 +33,9 @@ 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. +is the source of truth for portable structural validation. This specification defines the +operational meaning, semantic producer validation, and evolution rules that JSON Schema alone +does not express. ## Version history @@ -44,6 +45,7 @@ evolution rules that JSON Schema alone does not express. | 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 @@ -136,6 +138,13 @@ Unicode-character string without control characters, an authorization value, or 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 diff --git a/products/platform/CHANGELOG.md b/products/platform/CHANGELOG.md index 169f9d372..dc6ec9124 100644 --- a/products/platform/CHANGELOG.md +++ b/products/platform/CHANGELOG.md @@ -6,7 +6,10 @@ 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. + 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 diff --git a/schemas/registry-notary.config.schema.json b/schemas/registry-notary.config.schema.json index 4503a6ae3..fef2cacd7 100644 --- a/schemas/registry-notary.config.schema.json +++ b/schemas/registry-notary.config.schema.json @@ -650,15 +650,10 @@ "type": "string" }, "reference": { - "maxLength": 128, - "minLength": 1, - "pattern": "^(?!.*\\.\\.)[A-Za-z0-9._:-]+$", - "type": "string" + "$ref": "#/$defs/DeploymentWaiverReference" }, "summary": { - "maxLength": 256, - "minLength": 1, - "type": "string" + "$ref": "#/$defs/DeploymentWaiverSummary" } }, "required": [ @@ -668,6 +663,39 @@ ], "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 815344c7a..c4f022660 100644 --- a/schemas/registry-relay.config.schema.json +++ b/schemas/registry-relay.config.schema.json @@ -1516,15 +1516,10 @@ "type": "string" }, "reference": { - "maxLength": 128, - "minLength": 1, - "pattern": "^(?!.*\\.\\.)[A-Za-z0-9._:-]+$", - "type": "string" + "$ref": "#/$defs/DeploymentWaiverReference" }, "summary": { - "maxLength": 256, - "minLength": 1, - "type": "string" + "$ref": "#/$defs/DeploymentWaiverSummary" } }, "required": [ @@ -1534,6 +1529,39 @@ ], "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`.", From 662a984b7065af684e7dae68d02314683bab8c59 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Fri, 24 Jul 2026 00:36:07 +0700 Subject: [PATCH 45/58] fix(release): snapshot candidate inputs Signed-off-by: Jeremi Joslin --- release/scripts/conformance_candidate.py | 254 ++++++++++++++++++++++- release/scripts/test_relay_oidc_smoke.py | 159 ++++++++++++++ 2 files changed, 404 insertions(+), 9 deletions(-) diff --git a/release/scripts/conformance_candidate.py b/release/scripts/conformance_candidate.py index 10f9a1f09..441f5570f 100644 --- a/release/scripts/conformance_candidate.py +++ b/release/scripts/conformance_candidate.py @@ -6,12 +6,15 @@ 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 +from typing import Any, Callable, Iterator import yaml @@ -20,6 +23,9 @@ 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", @@ -36,6 +42,200 @@ 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( @@ -166,7 +366,18 @@ def find_named(items: Any, name: str, label: str) -> dict[str, Any]: def run_authenticity_command(command: list[str]) -> None: - result = subprocess.run(command, text=True, capture_output=True, check=False) + 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 "" @@ -331,20 +542,16 @@ def verify_release_asset_binding( return capsule_sha256 -def load_candidate( +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]: - manifest_path = require_regular_file(manifest_path, max_bytes=1024 * 1024) - image_lock_path = require_regular_file(image_lock_path, max_bytes=1024 * 1024) - if manifest_path.parent != MANIFEST_DIR.resolve(): - raise CandidateError(f"release manifest must be under {MANIFEST_DIR}") try: - manifest_bytes = manifest_path.read_bytes() - image_lock_bytes = image_lock_path.read_bytes() manifest = yaml.safe_load(manifest_bytes.decode("utf-8")) lock = json.loads(image_lock_bytes) except (UnicodeDecodeError, json.JSONDecodeError, yaml.YAMLError) as exc: @@ -443,3 +650,32 @@ def load_candidate( "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/test_relay_oidc_smoke.py b/release/scripts/test_relay_oidc_smoke.py index 7c5d7bfb4..6a3ccf7a6 100644 --- a/release/scripts/test_relay_oidc_smoke.py +++ b/release/scripts/test_relay_oidc_smoke.py @@ -7,6 +7,9 @@ import hashlib import importlib.util import json +import os +import stat +import subprocess import sys import tempfile import threading @@ -206,6 +209,24 @@ def write_candidate_evidence( "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") @@ -358,6 +379,144 @@ def test_candidate_binding_rejects_locally_replaced_release_assets(self) -> None ): 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: From 2a951b54e6d9e5de337c6dfa83ab9dc66d1a1701 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Fri, 24 Jul 2026 00:48:10 +0700 Subject: [PATCH 46/58] fix(platform): reject PGP waiver key markers Signed-off-by: Jeremi Joslin --- crates/registry-notary-core/src/deployment.rs | 1 + crates/registry-notary/tests/config_schema.rs | 4 ++++ crates/registry-platform-ops/src/lib.rs | 15 ++++++++++++--- .../tests/posture_profile_gates_contract.rs | 4 ++++ crates/registry-relay/tests/config_schema.rs | 4 ++++ .../tests/deployment_profile_gates.rs | 1 + docs/site/src/content/docs/spec/rs-op-posture.mdx | 15 ++++++++------- 7 files changed, 34 insertions(+), 10 deletions(-) diff --git a/crates/registry-notary-core/src/deployment.rs b/crates/registry-notary-core/src/deployment.rs index 266923401..8b5e6ca33 100644 --- a/crates/registry-notary-core/src/deployment.rs +++ b/crates/registry-notary-core/src/deployment.rs @@ -923,6 +923,7 @@ mod tests { "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()); diff --git a/crates/registry-notary/tests/config_schema.rs b/crates/registry-notary/tests/config_schema.rs index 4125d9367..220bbedd3 100644 --- a/crates/registry-notary/tests/config_schema.rs +++ b/crates/registry-notary/tests/config_schema.rs @@ -426,6 +426,10 @@ fn deployment_waiver_schema_matches_shared_portable_metadata_contract() { 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)), diff --git a/crates/registry-platform-ops/src/lib.rs b/crates/registry-platform-ops/src/lib.rs index 5188e097b..18b71756b 100644 --- a/crates/registry-platform-ops/src/lib.rs +++ b/crates/registry-platform-ops/src/lib.rs @@ -1378,9 +1378,11 @@ fn contains_high_confidence_credential_literal(summary: &str) -> bool { .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")) + suffix.split_once("-----").is_some_and(|(label, _)| { + label == "PRIVATE KEY" + || label.ends_with(" PRIVATE KEY") + || label == "PGP PRIVATE KEY BLOCK" + }) }) } @@ -4132,6 +4134,13 @@ mod tests { ), DeploymentWaiverMetadataError::SummaryCredentialLiteral, ), + ( + concat!( + "accidentally pasted -----BEGIN PGP PRIVATE KEY ", + "BLOCK-----" + ), + DeploymentWaiverMetadataError::SummaryCredentialLiteral, + ), ] { assert_eq!( validate_deployment_waiver_metadata("OPS-42", Some(summary)), 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 ab00448a5..7e02a3ce3 100644 --- a/crates/registry-platform-ops/tests/posture_profile_gates_contract.rs +++ b/crates/registry-platform-ops/tests/posture_profile_gates_contract.rs @@ -261,6 +261,10 @@ fn posture_schema_summary_acceptance_does_not_replace_semantic_validation() { 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)), diff --git a/crates/registry-relay/tests/config_schema.rs b/crates/registry-relay/tests/config_schema.rs index 30ac7569a..732a15d7c 100644 --- a/crates/registry-relay/tests/config_schema.rs +++ b/crates/registry-relay/tests/config_schema.rs @@ -381,6 +381,10 @@ fn deployment_waiver_schema_matches_shared_portable_metadata_contract() { 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)), diff --git a/crates/registry-relay/tests/deployment_profile_gates.rs b/crates/registry-relay/tests/deployment_profile_gates.rs index bce8ee192..b892e508a 100644 --- a/crates/registry-relay/tests/deployment_profile_gates.rs +++ b/crates/registry-relay/tests/deployment_profile_gates.rs @@ -187,6 +187,7 @@ fn invalid_waiver_summary_is_rejected() { "Basic credential-value", "rotated leaked Bearer abcdef", "-----BEGIN PRIVATE KEY-----", + concat!("-----BEGIN PGP PRIVATE KEY ", "BLOCK-----"), &"x".repeat(257), ] { let yaml = format!( 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 85a68653b..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,10 +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 portable structural validation. This specification defines the -operational meaning, semantic producer validation, 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 @@ -276,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). From ac964f9ed489a7092e5f92ffe3e362d95f2a9a1b Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Fri, 24 Jul 2026 01:35:26 +0700 Subject: [PATCH 47/58] fix(release): require candidate tag closeout Signed-off-by: Jeremi Joslin --- release/scripts/conformance_candidate.py | 26 ++--- release/scripts/test_relay_oidc_smoke.py | 42 ++++++++ .../scripts/test_validate_upgrade_exercise.py | 102 ++++++++++++++++++ 3 files changed, 158 insertions(+), 12 deletions(-) diff --git a/release/scripts/conformance_candidate.py b/release/scripts/conformance_candidate.py index 441f5570f..df9655f27 100644 --- a/release/scripts/conformance_candidate.py +++ b/release/scripts/conformance_candidate.py @@ -289,18 +289,6 @@ def verify_closeout_manifest_transition( manifest_bytes: bytes, tagged_manifest_bytes: bytes, ) -> None: - if manifest_bytes == tagged_manifest_bytes: - 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") try: tagged_manifest = yaml.safe_load(tagged_manifest_bytes.decode("utf-8")) except (UnicodeDecodeError, yaml.YAMLError): @@ -315,6 +303,20 @@ def verify_closeout_manifest_transition( 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: diff --git a/release/scripts/test_relay_oidc_smoke.py b/release/scripts/test_relay_oidc_smoke.py index 6a3ccf7a6..51d5e95e9 100644 --- a/release/scripts/test_relay_oidc_smoke.py +++ b/release/scripts/test_relay_oidc_smoke.py @@ -625,6 +625,48 @@ def test_candidate_binding_accepts_only_the_manifest_closeout_transition( candidate_module.verify_closeout_manifest_transition( {"status": "released"}, released, tagged ) + + real_git_output = candidate_module.git_output + + def released_tag(arguments, max_bytes): + 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" ): diff --git a/release/scripts/test_validate_upgrade_exercise.py b/release/scripts/test_validate_upgrade_exercise.py index 3b9813650..03ceb1ee0 100644 --- a/release/scripts/test_validate_upgrade_exercise.py +++ b/release/scripts/test_validate_upgrade_exercise.py @@ -1,6 +1,7 @@ from __future__ import annotations import copy +import contextlib import hashlib import importlib.util import io @@ -399,6 +400,107 @@ def test_missing_authentication_tools_or_assets_fail_closed(self) -> None: ) 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", self.real_load_candidate + ), 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: From 8d4ebd96b628e2f55ae6acfbdbc736ff3cd67787 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Fri, 24 Jul 2026 01:41:03 +0700 Subject: [PATCH 48/58] test(release): exercise equal closeout bytes Signed-off-by: Jeremi Joslin --- release/scripts/test_relay_oidc_smoke.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/release/scripts/test_relay_oidc_smoke.py b/release/scripts/test_relay_oidc_smoke.py index 51d5e95e9..bb22e1595 100644 --- a/release/scripts/test_relay_oidc_smoke.py +++ b/release/scripts/test_relay_oidc_smoke.py @@ -625,10 +625,18 @@ def test_candidate_binding_accepts_only_the_manifest_closeout_transition( 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) From 50d4197f824a3781354a9dc4327a5fbc54389a39 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Fri, 24 Jul 2026 01:46:08 +0700 Subject: [PATCH 49/58] fix(registryctl): close live evidence validation Signed-off-by: Jeremi Joslin --- crates/registryctl/src/project_authoring.rs | 1 + .../src/project_authoring/commands.rs | 111 ++++++-- .../src/project_authoring/tests.rs | 237 ++++++++++++++++++ .../project-authoring/openspp-exact/README.md | 18 +- 4 files changed, 338 insertions(+), 29 deletions(-) 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 67d82bd93..68643ae99 100644 --- a/crates/registryctl/src/project_authoring/commands.rs +++ b/crates/registryctl/src/project_authoring/commands.rs @@ -568,17 +568,12 @@ fn execute_governed_live_test(loaded: &LoadedRegistryProject) -> Result Result, +) -> bool { + matches!(environment, "prod" | "production") + || environment.starts_with("prod-") + || environment.starts_with("production-") + || environment.ends_with("-prod") + || environment.ends_with("-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") @@ -721,11 +731,7 @@ fn validate_live_response( let result_object = result .as_object() .ok_or_else(|| anyhow!("governed Notary result must be an object"))?; - if !result_object.contains_key("expires_at") { - bail!( - "governed Notary result does not match the closed public claim-result schema: expires_at is required" - ); - } + validate_live_result_raw_schema(result, result_object)?; let result_view: registry_notary_core::ClaimResultView = serde_json::from_value(result.clone()).map_err(|_| { anyhow!( @@ -741,16 +747,28 @@ fn validate_live_response( "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 result_view.format != registry_notary_core::FORMAT_CLAIM_RESULT_JSON { + bail!("governed Notary result has an invalid claim-result format"); + } + if OffsetDateTime::parse(&result_view.issued_at, &Rfc3339).is_err() + || result_view.expires_at.as_deref().is_some_and(|expires_at| { + OffsetDateTime::parse(expires_at, &Rfc3339).is_err() + }) + { + bail!("governed Notary result timestamps do not match the public date-time schema"); + } if !result_view.provenance.derived_from.is_empty() { bail!("governed Notary result provenance derived_from must remain empty"); } - let generated_by = result - .pointer("/provenance/generated_by") - .and_then(Value::as_object) - .ok_or_else(|| anyhow!("governed Notary result has invalid provenance"))?; - if generated_by.contains_key("pack_id") || generated_by.contains_key("pack_version") { - bail!("governed Notary result exceeds the closed public claim-result schema"); - } 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"); @@ -780,6 +798,53 @@ fn validate_live_response( Ok(returned.into_iter().collect()) } +// 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] = &[ + "/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", +]; + +// The transport model carries these fields, but the closed public +// ClaimResultView schema does not expose them. +const LIVE_RESULT_SCHEMA_EXCLUDED_PATHS: &[&str] = &[ + "/redacted_fields", + "/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_notary_origin(value: &str) -> Result { if value.len() > 2048 || value.trim() != value { bail!("live Notary origin has an invalid bounded shape"); diff --git a/crates/registryctl/src/project_authoring/tests.rs b/crates/registryctl/src/project_authoring/tests.rs index a060b4614..7ad286ca9 100644 --- a/crates/registryctl/src/project_authoring/tests.rs +++ b/crates/registryctl/src/project_authoring/tests.rs @@ -699,6 +699,94 @@ outputs: .expect("actual claim result serializes") } + fn governed_live_eligible_fixture() -> (Vec, Value, Value) { + ( + vec!["eligible".to_string()], + json!({ + "claims": { + "eligible": { + "value": true, + "satisfied": true, + "disclosure": "predicate", + }, + }, + }), + json!({ + "results": [governed_live_claim_result( + "eligible", + json!(true), + Some(true), + "predicate", + )], + }), + ) + } + + 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_production_guard_covers_name_and_profile_symmetry() { + for environment in [ + "prod", + "production", + "prod-us", + "production-us", + "us-prod", + "us-production", + ] { + 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", + ] { + 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 claims = vec!["eligible".to_string()]; @@ -846,6 +934,155 @@ outputs: } } + #[test] + fn governed_live_result_binds_provenance_to_returned_result() { + let (claims, expected, response) = governed_live_eligible_fixture(); + assert_eq!( + validate_live_response(&response, &claims, &expected) + .expect("canonical provenance binding passes"), + 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, &claims, &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 (claims, 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, &claims, &expected) + .expect("canonical RFC3339 timestamps pass"), + 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, &claims, &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_rejects_schema_excluded_redacted_fields() { + let (claims, expected, mut response) = governed_live_eligible_fixture(); + assert!( + response + .pointer("/results/0/redacted_fields") + .is_none(), + "canonical public result omits redacted_fields" + ); + set_governed_live_result_pointer( + &mut response, + "/redacted_fields", + json!(["private_field"]), + ); + + assert!( + validate_live_response(&response, &claims, &expected) + .expect_err("schema-excluded redacted_fields must fail closed") + .to_string() + .contains("exceeds the closed public claim-result schema") + ); + } + + #[test] + fn governed_live_result_rejects_null_optional_public_fields_recursively() { + let (claims, expected, mut response) = governed_live_eligible_fixture(); + for (pointer, value) in [ + ("/requester_ref/identifier_schemes", json!([])), + ("/requester_ref/profile", json!("requester")), + ("/provenance/generated_by/policy_id", json!("eligibility")), + ("/provenance/generated_by/policy_version", json!("1.0.0")), + ( + "/provenance/generated_by/policy_hash", + json!(format!("sha256:{}", "0".repeat(64))), + ), + ] { + set_governed_live_result_pointer(&mut response, pointer, value); + } + for pointer in LIVE_RESULT_OPTIONAL_NON_NULL_PATHS { + assert!( + response + .pointer(&format!("/results/0{pointer}")) + .is_some_and(|value| !value.is_null()), + "canonical optional public field {pointer} is absent or null" + ); + } + assert_eq!( + validate_live_response(&response, &claims, &expected) + .expect("non-null optional public fields pass"), + 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, &claims, &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_result_requires_claim_result_format() { + let (claims, 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, &claims, &expected) + .expect("canonical claim-result format passes"), + claims + ); + + set_governed_live_result_pointer(&mut response, "/format", json!("application/json")); + assert!( + validate_live_response(&response, &claims, &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 claims = vec!["eligible".to_string()]; diff --git a/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md b/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md index 24e311461..f7b6b83fd 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md +++ b/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md @@ -116,11 +116,13 @@ Its keys must exactly match the 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. The live runner rejects keys outside its accepted result, reference, and -provenance structures; does not accept provenance `pack_id` or `pack_version` -keys; requires exact `value`, `satisfied`, and `disclosure` matches; requires -an empty `derived_from` array; and requires a non-zero Relay consultation -count. It does not judge whether an otherwise allowed handle or identifier -value is semantically appropriate. +provenance structures, including `redacted_fields`, `pack_id`, and +`pack_version`; rejects nulls for optional public fields; requires the canonical +claim-result format and RFC3339 timestamps; binds each provenance record to its +returned evaluation, claim, and version; requires exact `value`, `satisfied`, +and `disclosure` matches; requires an empty `derived_from` array; and requires a +non-zero Relay consultation count. It does not judge whether an otherwise +allowed handle or identifier value is semantically appropriate. This example reflects only the committed synthetic fixture and must be replaced with reviewed expectations for the owner-approved record: @@ -167,7 +169,11 @@ other three variables, and run: registryctl test --project-dir . --environment --live ``` -The command refuses a production environment. +The command refuses environment names `prod`, `production`, `prod-*`, +`production-*`, `*-prod`, and `*-production`, 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 From 623e1890c9c5779d5da5ca85a789e5bd7502475e Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Fri, 24 Jul 2026 03:56:18 +0700 Subject: [PATCH 50/58] fix(registryctl): validate governed result identity Signed-off-by: Jeremi Joslin --- crates/registry-notary-server/src/openapi.rs | 14 + .../src/project_authoring/commands.rs | 146 ++++++- .../src/project_authoring/tests.rs | 375 +++++++++++++++--- .../project-authoring/openspp-exact/README.md | 119 ++++-- .../openapi/registry-notary.openapi.json | 7 + 5 files changed, 564 insertions(+), 97 deletions(-) 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/registryctl/src/project_authoring/commands.rs b/crates/registryctl/src/project_authoring/commands.rs index 68643ae99..d2eea1c15 100644 --- a/crates/registryctl/src/project_authoring/commands.rs +++ b/crates/registryctl/src/project_authoring/commands.rs @@ -588,7 +588,7 @@ fn execute_governed_live_test(loaded: &LoadedRegistryProject) -> Result Result Result<()> { fn validate_live_response( response: &Value, - requested_claims: &[String], + request: &ValidatedLiveRequest, expected: &Value, ) -> Result> { let object = response @@ -710,10 +710,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::>(); @@ -727,6 +728,7 @@ 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; for result in results { let result_object = result .as_object() @@ -756,6 +758,13 @@ fn validate_live_response( "governed Notary result provenance does not identify the returned claim result" ); } + 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"); } @@ -769,10 +778,16 @@ fn validate_live_response( if !result_view.provenance.derived_from.is_empty() { bail!("governed Notary result provenance derived_from must remain empty"); } + validate_live_result_redaction(&result_view)?; 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"); + } let expected_result = expected[claim_id] .as_object() .ok_or_else(|| anyhow!("live expected claim result must be an object"))?; @@ -802,6 +817,7 @@ fn validate_live_response( // 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", @@ -813,10 +829,7 @@ const LIVE_RESULT_OPTIONAL_NON_NULL_PATHS: &[&str] = &[ "/provenance/generated_by/policy_hash", ]; -// The transport model carries these fields, but the closed public -// ClaimResultView schema does not expose them. const LIVE_RESULT_SCHEMA_EXCLUDED_PATHS: &[&str] = &[ - "/redacted_fields", "/provenance/generated_by/pack_id", "/provenance/generated_by/pack_version", ]; @@ -845,6 +858,53 @@ fn validate_live_result_raw_schema( Ok(()) } +fn validate_live_result_redaction(result: ®istry_notary_core::ClaimResultView) -> 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 => { + if result.value.is_some() + || result.satisfied.is_some() + || result.redacted_fields.is_empty() + { + bail!("governed Notary result violates full-redaction semantics"); + } + } + registry_notary_core::DisclosureProfile::Predicate => { + if !result.redacted_fields.is_empty() { + bail!("governed Notary result exposes a predicate over redacted fields"); + } + } + registry_notary_core::DisclosureProfile::Value => { + 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() + || unique.len() != result.redacted_fields.len() + || unique.iter().any(|field| { + field.is_empty() + || *field == "value" + || field.contains('.') + || field.contains('[') + || field.contains(']') + || value.contains_key(*field) + }) + { + bail!("governed Notary result has invalid field-redaction semantics"); + } + } + } + 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"); @@ -951,7 +1011,16 @@ 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, +} + +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"))?; @@ -979,26 +1048,63 @@ 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, + }) } fn contains_sensitive_request_key(value: &Value) -> bool { diff --git a/crates/registryctl/src/project_authoring/tests.rs b/crates/registryctl/src/project_authoring/tests.rs index 7ad286ca9..55e1bf1f2 100644 --- a/crates/registryctl/src/project_authoring/tests.rs +++ b/crates/registryctl/src/project_authoring/tests.rs @@ -699,9 +699,19 @@ outputs: .expect("actual claim result serializes") } - fn governed_live_eligible_fixture() -> (Vec, Value, Value) { + 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(), + } + } + + fn governed_live_eligible_fixture() -> (ValidatedLiveRequest, Value, Value) { ( - vec!["eligible".to_string()], + governed_live_validated_request(&["eligible"]), json!({ "claims": { "eligible": { @@ -789,7 +799,7 @@ outputs: #[test] fn governed_live_result_accepts_actual_shape_and_requires_source_provenance() { - let claims = vec!["eligible".to_string()]; + let request = governed_live_validated_request(&["eligible"]); let expected = json!({ "claims": { "eligible": { @@ -808,15 +818,15 @@ outputs: )], }); 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") @@ -825,7 +835,7 @@ outputs: #[test] fn governed_live_result_requires_explicit_expires_at() { - let claims = vec!["eligible".to_string()]; + let request = governed_live_validated_request(&["eligible"]); let expected = json!({ "claims": { "eligible": { @@ -849,9 +859,9 @@ outputs: Some(&Value::Null) ); assert_eq!( - validate_live_response(&response, &claims, &expected) + validate_live_response(&response, &request, &expected) .expect("explicit null expires_at passes"), - claims + request.claims ); let mut missing_expires_at = response; @@ -863,7 +873,7 @@ outputs: Some(Value::Null) ); assert!( - validate_live_response(&missing_expires_at, &claims, &expected) + validate_live_response(&missing_expires_at, &request, &expected) .expect_err("missing expires_at must fail closed") .to_string() .contains("expires_at is required") @@ -872,7 +882,7 @@ outputs: #[test] fn governed_live_result_requires_canonical_provenance_constants() { - let claims = vec!["eligible".to_string()]; + let request = governed_live_validated_request(&["eligible"]); let expected = json!({ "claims": { "eligible": { @@ -904,9 +914,9 @@ outputs: )) ); assert_eq!( - validate_live_response(&response, &claims, &expected) + validate_live_response(&response, &request, &expected) .expect("canonical provenance constants pass"), - claims + request.claims ); for (pointer, value) in [ @@ -925,7 +935,7 @@ outputs: .expect("actual provenance constant exists") = value; assert!( - validate_live_response(&invalid, &claims, &expected) + validate_live_response(&invalid, &request, &expected) .expect_err("non-canonical provenance constant must fail closed") .to_string() .contains("provenance constants"), @@ -936,11 +946,11 @@ outputs: #[test] fn governed_live_result_binds_provenance_to_returned_result() { - let (claims, expected, response) = governed_live_eligible_fixture(); + let (request, expected, response) = governed_live_eligible_fixture(); assert_eq!( - validate_live_response(&response, &claims, &expected) + validate_live_response(&response, &request, &expected) .expect("canonical provenance binding passes"), - claims + request.claims ); for (pointer, value) in [ @@ -958,7 +968,7 @@ outputs: set_governed_live_result_pointer(&mut invalid, pointer, value); assert!( - validate_live_response(&invalid, &claims, &expected) + validate_live_response(&invalid, &request, &expected) .expect_err("misbound provenance must fail closed") .to_string() .contains("does not identify the returned claim result"), @@ -969,16 +979,16 @@ outputs: #[test] fn governed_live_result_requires_public_date_time_timestamps() { - let (claims, expected, mut response) = governed_live_eligible_fixture(); + 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, &claims, &expected) + validate_live_response(&response, &request, &expected) .expect("canonical RFC3339 timestamps pass"), - claims + request.claims ); for pointer in ["/issued_at", "/expires_at"] { @@ -986,7 +996,7 @@ outputs: set_governed_live_result_pointer(&mut invalid, pointer, json!("not-rfc3339")); assert!( - validate_live_response(&invalid, &claims, &expected) + validate_live_response(&invalid, &request, &expected) .expect_err("invalid public timestamp must fail closed") .to_string() .contains("public date-time schema"), @@ -996,31 +1006,209 @@ outputs: } #[test] - fn governed_live_result_rejects_schema_excluded_redacted_fields() { - let (claims, expected, mut response) = governed_live_eligible_fixture(); + 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(), - "canonical public result omits redacted_fields" + "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; + 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") + ); + } + + #[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")); + + 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(&response, &claims, &expected) - .expect_err("schema-excluded redacted_fields must fail closed") + 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_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::>() + ); + + 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_version_matches_authored_request() { + let loaded = load_registry_project(&project_golden("openspp-exact"), None) + .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"); + 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("exceeds the closed public claim-result schema") + .contains("does not match the authored project") ); } #[test] fn governed_live_result_rejects_null_optional_public_fields_recursively() { - let (claims, expected, mut response) = governed_live_eligible_fixture(); + 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")), @@ -1042,9 +1230,9 @@ outputs: ); } assert_eq!( - validate_live_response(&response, &claims, &expected) + validate_live_response(&response, &request, &expected) .expect("non-null optional public fields pass"), - claims + request.claims ); for pointer in LIVE_RESULT_OPTIONAL_NON_NULL_PATHS { @@ -1052,7 +1240,7 @@ outputs: set_governed_live_result_pointer(&mut invalid, pointer, Value::Null); assert!( - validate_live_response(&invalid, &claims, &expected) + validate_live_response(&invalid, &request, &expected) .expect_err("null optional public field must fail closed") .to_string() .contains("optional public field cannot be null"), @@ -1063,20 +1251,20 @@ outputs: #[test] fn governed_live_result_requires_claim_result_format() { - let (claims, expected, mut response) = governed_live_eligible_fixture(); + 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, &claims, &expected) + validate_live_response(&response, &request, &expected) .expect("canonical claim-result format passes"), - claims + request.claims ); set_governed_live_result_pointer(&mut response, "/format", json!("application/json")); assert!( - validate_live_response(&response, &claims, &expected) + validate_live_response(&response, &request, &expected) .expect_err("wrong result format must fail closed") .to_string() .contains("invalid claim-result format") @@ -1085,7 +1273,7 @@ outputs: #[test] fn governed_live_result_rejects_partial_disclosure_expectations() { - let claims = vec!["eligible".to_string()]; + let request = governed_live_validated_request(&["eligible"]); let expected = json!({ "claims": { "eligible": { @@ -1104,7 +1292,7 @@ outputs: }); assert!( - validate_live_response(&response, &claims, &expected) + validate_live_response(&response, &request, &expected) .expect_err("partial expected disclosure must fail closed") .to_string() .contains("must contain exactly value, satisfied, and disclosure") @@ -1113,7 +1301,7 @@ outputs: #[test] fn governed_live_result_rejects_unknown_result_fields() { - let claims = vec!["eligible".to_string()]; + let request = governed_live_validated_request(&["eligible"]); let expected = json!({ "claims": { "eligible": { @@ -1134,7 +1322,7 @@ outputs: response["results"][0]["raw_value"] = json!("unexpected source value"); assert!( - validate_live_response(&response, &claims, &expected) + validate_live_response(&response, &request, &expected) .expect_err("unknown result fields must fail closed") .to_string() .contains("closed public claim-result schema") @@ -1143,7 +1331,7 @@ outputs: #[test] fn governed_live_result_rejects_nested_unknown_fields() { - let claims = vec!["eligible".to_string()]; + let request = governed_live_validated_request(&["eligible"]); let expected = json!({ "claims": { "eligible": { @@ -1177,7 +1365,7 @@ outputs: .insert(field.to_string(), json!("private")); assert!( - validate_live_response(&over_disclosed, &claims, &expected) + validate_live_response(&over_disclosed, &request, &expected) .expect_err("nested unknown fields must fail closed") .to_string() .contains("closed public claim-result schema"), @@ -1188,7 +1376,7 @@ outputs: #[test] fn governed_live_result_requires_empty_derived_from() { - let claims = vec!["eligible".to_string()]; + let request = governed_live_validated_request(&["eligible"]); let expected = json!({ "claims": { "eligible": { @@ -1210,7 +1398,7 @@ outputs: json!([{ "raw_source_row": "private" }]); assert!( - validate_live_response(&response, &claims, &expected) + validate_live_response(&response, &request, &expected) .expect_err("non-empty derived_from must fail closed") .to_string() .contains("derived_from must remain empty") @@ -1219,7 +1407,7 @@ outputs: #[test] fn governed_live_result_rejects_provenance_fields_outside_public_schema() { - let claims = vec!["eligible".to_string()]; + let request = governed_live_validated_request(&["eligible"]); let expected = json!({ "claims": { "eligible": { @@ -1244,7 +1432,7 @@ outputs: unsupported["results"][0]["provenance"]["generated_by"][field] = value; assert!( - validate_live_response(&unsupported, &claims, &expected) + 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"), @@ -1269,7 +1457,7 @@ outputs: #[test] fn governed_live_report_does_not_infer_a_source_outcome() { - let claims = vec!["record-exists".to_string()]; + let request = governed_live_validated_request(&["record-exists"]); let expected = json!({ "claims": { "record-exists": { @@ -1288,10 +1476,10 @@ outputs: )], }); let returned_claims = - validate_live_response(&response, &claims, &expected).expect("no-match result passes"); + validate_live_response(&response, &request, &expected).expect("no-match result passes"); let report = governed_live_fixture_report(returned_claims); - assert_eq!(report.claims, claims); + assert_eq!(report.claims, request.claims); assert_eq!(report.outcome, None); assert!( serde_json::to_value(report) @@ -1349,6 +1537,80 @@ outputs: )); } + #[test] + fn live_request_requires_one_compatible_disclosure_profile() { + let loaded = load_registry_project(&project_golden("openspp-exact"), None) + .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"), None) + .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"); @@ -1397,15 +1659,26 @@ 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"], + "disclosure": "redacted", }), ) .expect("claims from both same-purpose services are valid"); - assert_eq!(claims, ["household-category", "household-eligible"]); + assert_eq!( + request.claims, + ["household-category", "household-eligible"] + ); + assert_eq!( + request.claim_versions, + BTreeMap::from([ + ("household-category".to_string(), "1".to_string()), + ("household-eligible".to_string(), "1".to_string()), + ]) + ); } #[test] diff --git a/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md b/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md index f7b6b83fd..18c39b8cf 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md +++ b/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md @@ -81,9 +81,15 @@ 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. -The following request shape matches the committed synthetic project, so replace -the identifier scheme, value, purpose, and claim ids when the authored project -changes: +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 { @@ -98,33 +104,15 @@ changes: }, "claims": [ "social-registry-record-exists", - "social-registry-active", - "programme-code", - "household-reference" + "social-registry-active" ], + "disclosure": "predicate", "format": "application/vnd.registry-notary.claim-result+json", "purpose": "social-programme-verification" } ``` -`registryctl` sends -`Accept: application/vnd.registry-notary.claim-result+json`, matching the -request's `format`, and validates that claim-result envelope. - -The expected-result file must contain only a `claims` object. -Its keys must exactly match the 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. -The live runner rejects keys outside its accepted result, reference, and -provenance structures, including `redacted_fields`, `pack_id`, and -`pack_version`; rejects nulls for optional public fields; requires the canonical -claim-result format and RFC3339 timestamps; binds each provenance record to its -returned evaluation, claim, and version; requires exact `value`, `satisfied`, -and `disclosure` matches; requires an empty `derived_from` array; and requires a -non-zero Relay consultation count. It does not judge whether an otherwise -allowed handle or identifier value is semantically appropriate. -This example reflects only the committed synthetic fixture and must be replaced -with reviewed expectations for the owner-approved record: +Predicate expected result: ```json { @@ -138,12 +126,70 @@ with reviewed expectations for the owner-approved record: "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, @@ -153,6 +199,27 @@ with reviewed expectations for the owner-approved record: } ``` +`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. +The live runner rejects keys outside its accepted result, reference, and +provenance structures, including `pack_id` and `pack_version`; validates +public `redacted_fields` without exposing a listed field value; rejects nulls +for optional public fields; requires one evaluation id, the canonical +claim-result format, and RFC3339 timestamps; 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. It does +not judge whether an otherwise allowed handle or identifier value is +semantically appropriate. +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 | 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" }, From 0ce574c244501a4a4ddeb1d2d3368d4c9aa5773d Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Fri, 24 Jul 2026 05:18:46 +0700 Subject: [PATCH 51/58] fix(registryctl): bind live Notary service Signed-off-by: Jeremi Joslin --- .../src/project_authoring/commands.rs | 11 ++++ .../src/project_authoring/tests.rs | 62 +++++++++++++++++-- 2 files changed, 69 insertions(+), 4 deletions(-) diff --git a/crates/registryctl/src/project_authoring/commands.rs b/crates/registryctl/src/project_authoring/commands.rs index d2eea1c15..4150c8066 100644 --- a/crates/registryctl/src/project_authoring/commands.rs +++ b/crates/registryctl/src/project_authoring/commands.rs @@ -758,6 +758,9 @@ fn validate_live_response( "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 evaluation_id .as_ref() .is_some_and(|evaluation_id| evaluation_id != &result_view.evaluation_id) @@ -1015,6 +1018,7 @@ mod external_request_reader_tests { struct ValidatedLiveRequest { claims: Vec, claim_versions: BTreeMap, + notary_service_id: String, } fn validate_live_request( @@ -1027,6 +1031,12 @@ fn validate_live_request( 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) @@ -1104,6 +1114,7 @@ fn validate_live_request( Ok(ValidatedLiveRequest { claims: ids, claim_versions, + notary_service_id, }) } diff --git a/crates/registryctl/src/project_authoring/tests.rs b/crates/registryctl/src/project_authoring/tests.rs index 55e1bf1f2..b62dfa9c5 100644 --- a/crates/registryctl/src/project_authoring/tests.rs +++ b/crates/registryctl/src/project_authoring/tests.rs @@ -706,6 +706,7 @@ outputs: .iter() .map(|claim| ((*claim).to_string(), "1.0.0".to_string())) .collect(), + notary_service_id: "registry-notary".to_string(), } } @@ -1157,7 +1158,7 @@ outputs: #[test] fn governed_live_result_claim_version_matches_authored_request() { - let loaded = load_registry_project(&project_golden("openspp-exact"), None) + let loaded = load_registry_project(&project_golden("openspp-exact"), Some("local")) .expect("OpenSPP golden project loads"); let request = validate_live_request( &loaded, @@ -1187,6 +1188,8 @@ outputs: }); 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"), @@ -1205,6 +1208,56 @@ outputs: ); } + #[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_rejects_null_optional_public_fields_recursively() { let (request, expected, mut response) = governed_live_eligible_fixture(); @@ -1539,7 +1592,7 @@ outputs: #[test] fn live_request_requires_one_compatible_disclosure_profile() { - let loaded = load_registry_project(&project_golden("openspp-exact"), None) + let loaded = load_registry_project(&project_golden("openspp-exact"), Some("local")) .expect("OpenSPP golden project loads"); for (disclosure, claims) in [ @@ -1581,7 +1634,7 @@ outputs: #[test] fn live_request_claim_version_must_match_the_authored_service() { - let loaded = load_registry_project(&project_golden("openspp-exact"), None) + let loaded = load_registry_project(&project_golden("openspp-exact"), Some("local")) .expect("OpenSPP golden project loads"); let request = |version| { json!({ @@ -1614,7 +1667,8 @@ outputs: #[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]) From ce9e33b6d0634f07203924eaf79ca11a574e53f9 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Fri, 24 Jul 2026 05:42:25 +0700 Subject: [PATCH 52/58] fix(registryctl): harden governed live evidence Signed-off-by: Jeremi Joslin --- .../src/project_authoring/commands.rs | 40 ++++++++-- .../src/project_authoring/tests.rs | 79 ++++++++++++++++++- .../project-authoring/openspp-exact/README.md | 22 +++--- 3 files changed, 121 insertions(+), 20 deletions(-) diff --git a/crates/registryctl/src/project_authoring/commands.rs b/crates/registryctl/src/project_authoring/commands.rs index 4150c8066..ea16f471c 100644 --- a/crates/registryctl/src/project_authoring/commands.rs +++ b/crates/registryctl/src/project_authoring/commands.rs @@ -623,11 +623,9 @@ fn governed_live_environment_is_production( environment: &str, deployment_profile: Option, ) -> bool { - matches!(environment, "prod" | "production") - || environment.starts_with("prod-") - || environment.starts_with("production-") - || environment.ends_with("-prod") - || environment.ends_with("-production") + environment + .split(['-', '_', '.']) + .any(|segment| matches!(segment, "prod" | "production")) || matches!( deployment_profile, Some(DeploymentProfile::Production | DeploymentProfile::EvidenceGrade) @@ -781,7 +779,7 @@ fn validate_live_response( if !result_view.provenance.derived_from.is_empty() { bail!("governed Notary result provenance derived_from must remain empty"); } - validate_live_result_redaction(&result_view)?; + validate_live_result_reference_handles(&result_view)?; 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"); @@ -791,6 +789,7 @@ fn validate_live_response( { bail!("governed Notary result claim version does not match the authored project"); } + validate_live_result_redaction(&result_view)?; let expected_result = expected[claim_id] .as_object() .ok_or_else(|| anyhow!("live expected claim result must be an object"))?; @@ -861,6 +860,32 @@ fn validate_live_result_raw_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) -> Result<()> { let disclosure = registry_notary_core::DisclosureProfile::parse(&result.disclosure) .ok_or_else(|| anyhow!("governed Notary result has an invalid disclosure profile"))?; @@ -868,7 +893,8 @@ fn validate_live_result_redaction(result: ®istry_notary_core::ClaimResultView registry_notary_core::DisclosureProfile::Redacted => { if result.value.is_some() || result.satisfied.is_some() - || result.redacted_fields.is_empty() + || result.redacted_fields.len() != 1 + || result.redacted_fields[0] != result.claim_id { bail!("governed Notary result violates full-redaction semantics"); } diff --git a/crates/registryctl/src/project_authoring/tests.rs b/crates/registryctl/src/project_authoring/tests.rs index b62dfa9c5..667cde3b0 100644 --- a/crates/registryctl/src/project_authoring/tests.rs +++ b/crates/registryctl/src/project_authoring/tests.rs @@ -669,13 +669,13 @@ outputs: subject_type: "Person".to_string(), requester_ref: Some(registry_notary_core::EvidenceEntityRef { entity_type: "Organisation".to_string(), - handle: "rnref:v1:requester".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: "rnref:v1:target".to_string(), + handle: format!("rnref:v1:hmac-sha256:{}", "2".repeat(64)), identifier_schemes: vec!["openspp_individual_id".to_string()], profile: Some("openspp".to_string()), }, @@ -760,6 +760,11 @@ outputs: "production-us", "us-prod", "us-production", + "prod_us", + "production.eu", + "eu_prod", + "eu.production", + "owner-prod-copy", ] { assert!( governed_live_environment_is_production( @@ -774,6 +779,8 @@ outputs: "preproduction", "productionish", "product-copy-us", + "owner-productionish-copy", + "eu_product", ] { assert!( !governed_live_environment_is_production( @@ -1044,7 +1051,7 @@ outputs: request.claims ); - let mut missing_evidence = response; + let mut missing_evidence = response.clone(); missing_evidence["results"][0] .as_object_mut() .expect("actual result is an object") @@ -1055,6 +1062,26 @@ outputs: .to_string() .contains("full-redaction semantics") ); + + 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")); + } } #[test] @@ -1258,6 +1285,52 @@ outputs: 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(); diff --git a/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md b/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md index 18c39b8cf..c68b3fc4a 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md +++ b/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md @@ -209,14 +209,16 @@ Each claim value must contain exactly `value`, `satisfied`, and `disclosure`, using `null` when the Notary result has no value or satisfaction decision. The live runner rejects keys outside its accepted result, reference, and provenance structures, including `pack_id` and `pack_version`; validates -public `redacted_fields` without exposing a listed field value; rejects nulls -for optional public fields; requires one evaluation id, the canonical -claim-result format, and RFC3339 timestamps; 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. It does -not judge whether an otherwise allowed handle or identifier value is -semantically appropriate. +public `redacted_fields`, including the current claim-id marker for full +redaction, without exposing a listed field value; requires `target_ref` and +optional `requester_ref` handles to use the Notary `rnref:v1` pseudonymous +SHA-256 shape; rejects nulls for optional public fields; requires one evaluation +id, the canonical claim-result format, and RFC3339 timestamps; 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. @@ -236,8 +238,8 @@ other three variables, and run: registryctl test --project-dir . --environment --live ``` -The command refuses environment names `prod`, `production`, `prod-*`, -`production-*`, `*-prod`, and `*-production`, plus environments whose +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. From cc304b58b7c618ba1b07716fb7ff18c89656d618 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Fri, 24 Jul 2026 05:56:55 +0700 Subject: [PATCH 53/58] fix(registryctl): bind configured redaction markers Signed-off-by: Jeremi Joslin --- .../src/project_authoring/commands.rs | 59 ++++++++++++++++--- .../src/project_authoring/tests.rs | 54 ++++++++++++++++- .../project-authoring/openspp-exact/README.md | 23 +++++--- 3 files changed, 118 insertions(+), 18 deletions(-) diff --git a/crates/registryctl/src/project_authoring/commands.rs b/crates/registryctl/src/project_authoring/commands.rs index ea16f471c..4770ef6d2 100644 --- a/crates/registryctl/src/project_authoring/commands.rs +++ b/crates/registryctl/src/project_authoring/commands.rs @@ -789,20 +789,26 @@ fn validate_live_response( { bail!("governed Notary result claim version does not match the authored project"); } - validate_live_result_redaction(&result_view)?; 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() .map(String::as_str) - .collect::>() - != BTreeSet::from(["disclosure", "satisfied", "value"]) + .collect::>(); + if expected_keys != BTreeSet::from(["disclosure", "satisfied", "value"]) + && expected_keys + != BTreeSet::from(["disclosure", "redacted_fields", "satisfied", "value"]) { bail!( - "live expected claim result must contain exactly value, satisfied, and disclosure" + "live expected claim result must contain value, satisfied, disclosure, and optional redacted_fields" ); } + 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"); @@ -886,25 +892,36 @@ fn is_notary_pseudonymous_handle(value: &str) -> bool { }) } -fn validate_live_result_redaction(result: ®istry_notary_core::ClaimResultView) -> Result<()> { +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.len() != 1 - || result.redacted_fields[0] != result.claim_id + || 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() { + if !result.redacted_fields.is_empty() || expected_redacted_fields.is_some() { bail!("governed Notary result exposes a predicate over redacted fields"); } } 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.redacted_fields.is_empty() { return Ok(()); } @@ -934,6 +951,30 @@ fn validate_live_result_redaction(result: ®istry_notary_core::ClaimResultView 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"); diff --git a/crates/registryctl/src/project_authoring/tests.rs b/crates/registryctl/src/project_authoring/tests.rs index 667cde3b0..31a7d08ea 100644 --- a/crates/registryctl/src/project_authoring/tests.rs +++ b/crates/registryctl/src/project_authoring/tests.rs @@ -1063,6 +1063,40 @@ outputs: .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!([""]), @@ -1082,6 +1116,24 @@ outputs: 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] @@ -1421,7 +1473,7 @@ outputs: validate_live_response(&response, &request, &expected) .expect_err("partial expected disclosure must fail closed") .to_string() - .contains("must contain exactly value, satisfied, and disclosure") + .contains("must contain value, satisfied, disclosure") ); } diff --git a/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md b/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md index c68b3fc4a..9854665ba 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md +++ b/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md @@ -207,16 +207,23 @@ 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`, including the current claim-id marker for full -redaction, without exposing a listed field value; requires `target_ref` and -optional `requester_ref` handles to use the Notary `rnref:v1` pseudonymous -SHA-256 shape; rejects nulls for optional public fields; requires one evaluation -id, the canonical claim-result format, and RFC3339 timestamps; 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. +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; rejects nulls for optional public fields; requires +one evaluation id, the canonical claim-result format, and RFC3339 timestamps; +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 From 970b168f44850134aee7f6c22c158ac1dc934be0 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Fri, 24 Jul 2026 06:13:58 +0700 Subject: [PATCH 54/58] fix(registryctl): validate field redaction markers Signed-off-by: Jeremi Joslin --- .../src/project_authoring/commands.rs | 8 +--- .../src/project_authoring/tests.rs | 38 +++++++++++++++++++ 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/crates/registryctl/src/project_authoring/commands.rs b/crates/registryctl/src/project_authoring/commands.rs index 4770ef6d2..8bd5f3296 100644 --- a/crates/registryctl/src/project_authoring/commands.rs +++ b/crates/registryctl/src/project_authoring/commands.rs @@ -934,14 +934,10 @@ fn validate_live_result_redaction( .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| { - field.is_empty() - || *field == "value" - || field.contains('.') - || field.contains('[') - || field.contains(']') - || value.contains_key(*field) + !is_live_top_level_redaction_field(field) || value.contains_key(*field) }) { bail!("governed Notary result has invalid field-redaction semantics"); diff --git a/crates/registryctl/src/project_authoring/tests.rs b/crates/registryctl/src/project_authoring/tests.rs index 31a7d08ea..a1d9743dd 100644 --- a/crates/registryctl/src/project_authoring/tests.rs +++ b/crates/registryctl/src/project_authoring/tests.rs @@ -1175,6 +1175,44 @@ outputs: 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( From 9b2ef8e5700eab6d188fa3f8749076a2701689a6 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Fri, 24 Jul 2026 06:46:37 +0700 Subject: [PATCH 55/58] fix(registryctl): bind live evidence to current run Signed-off-by: Jeremi Joslin --- .../src/project_authoring/commands.rs | 96 ++++++++- .../src/project_authoring/tests.rs | 197 ++++++++++++++++-- .../project-authoring/openspp-exact/README.md | 17 +- 3 files changed, 280 insertions(+), 30 deletions(-) diff --git a/crates/registryctl/src/project_authoring/commands.rs b/crates/registryctl/src/project_authoring/commands.rs index 8bd5f3296..096c294a2 100644 --- a/crates/registryctl/src/project_authoring/commands.rs +++ b/crates/registryctl/src/project_authoring/commands.rs @@ -563,6 +563,17 @@ 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 @@ -601,7 +612,9 @@ fn execute_governed_live_test(loaded: &LoadedRegistryProject) -> 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, &validated_request, &expected)?; + let returned_claims = validate_live_response( + &response, + &validated_request, + &expected, + GovernedLiveValidationWindow { + request_started_at, + response_received_at, + }, + )?; Ok(governed_live_fixture_report(returned_claims)) } @@ -698,6 +720,7 @@ fn validate_live_response( response: &Value, request: &ValidatedLiveRequest, expected: &Value, + validation_window: GovernedLiveValidationWindow, ) -> Result> { let object = response .as_object() @@ -727,6 +750,8 @@ fn validate_live_response( } 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_object = result .as_object() @@ -759,6 +784,12 @@ fn validate_live_response( 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) @@ -769,17 +800,29 @@ fn validate_live_response( if result_view.format != registry_notary_core::FORMAT_CLAIM_RESULT_JSON { bail!("governed Notary result has an invalid claim-result format"); } - if OffsetDateTime::parse(&result_view.issued_at, &Rfc3339).is_err() - || result_view.expires_at.as_deref().is_some_and(|expires_at| { - OffsetDateTime::parse(expires_at, &Rfc3339).is_err() - }) - { - bail!("governed Notary result timestamps do not match the public date-time schema"); - } + 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"); @@ -821,6 +864,41 @@ fn validate_live_response( 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. diff --git a/crates/registryctl/src/project_authoring/tests.rs b/crates/registryctl/src/project_authoring/tests.rs index a1d9743dd..8082307ee 100644 --- a/crates/registryctl/src/project_authoring/tests.rs +++ b/crates/registryctl/src/project_authoring/tests.rs @@ -733,6 +733,26 @@ outputs: ) } + 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") @@ -1013,6 +1033,76 @@ outputs: } } + #[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"]); @@ -1261,6 +1351,51 @@ outputs: 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"] = @@ -1428,26 +1563,12 @@ outputs: for (pointer, value) in [ ("/requester_ref/identifier_schemes", json!([])), ("/requester_ref/profile", json!("requester")), - ("/provenance/generated_by/policy_id", json!("eligibility")), - ("/provenance/generated_by/policy_version", json!("1.0.0")), - ( - "/provenance/generated_by/policy_hash", - json!(format!("sha256:{}", "0".repeat(64))), - ), ] { set_governed_live_result_pointer(&mut response, pointer, value); } - for pointer in LIVE_RESULT_OPTIONAL_NON_NULL_PATHS { - assert!( - response - .pointer(&format!("/results/0{pointer}")) - .is_some_and(|value| !value.is_null()), - "canonical optional public field {pointer} is absent or null" - ); - } assert_eq!( validate_live_response(&response, &request, &expected) - .expect("non-null optional public fields pass"), + .expect("canonical optional public fields pass"), request.claims ); @@ -1465,6 +1586,52 @@ outputs: } } + #[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(); diff --git a/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md b/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md index 9854665ba..578fdc65d 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md +++ b/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md @@ -218,12 +218,17 @@ 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; rejects nulls for optional public fields; requires -one evaluation id, the canonical claim-result format, and RFC3339 timestamps; -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. +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 From a0e9313fb37428ddabb38a0e34e7ce56de713db5 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Fri, 24 Jul 2026 07:23:35 +0700 Subject: [PATCH 56/58] fix(release): complete tutorial Debian 13 migration Signed-off-by: Jeremi Joslin --- .github/workflows/ci.yml | 7 +++-- .../scripts/check-registryctl-tutorials.sh | 2 +- release/scripts/check-debian13-images.py | 25 ++++++++++++----- release/scripts/check-gates-inventory.py | 4 +++ release/scripts/test_check_gates_inventory.py | 7 +++++ release/scripts/test_registry_release.py | 28 +++++++++++++++++++ 6 files changed, 62 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6991b07fd..ae756585c 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 @@ -644,9 +647,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/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/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..331000dd3 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", diff --git a/release/scripts/test_check_gates_inventory.py b/release/scripts/test_check_gates_inventory.py index a050580f3..8b641ebb0 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)", 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") From c788d49eb11c2c50b6cdcad8a3bc17570b2eb20a Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Fri, 24 Jul 2026 15:35:13 +0700 Subject: [PATCH 57/58] fix(release): normalize evidence digest coordinates Signed-off-by: Jeremi Joslin --- .../scripts/test_prepare_upgrade_exercise_assets.py | 6 +++--- release/scripts/test_validate_upgrade_exercise.py | 12 ++++++++++++ release/scripts/validate-upgrade-exercise.py | 6 +++++- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/release/scripts/test_prepare_upgrade_exercise_assets.py b/release/scripts/test_prepare_upgrade_exercise_assets.py index 278fe7b68..3cc199e6c 100644 --- a/release/scripts/test_prepare_upgrade_exercise_assets.py +++ b/release/scripts/test_prepare_upgrade_exercise_assets.py @@ -5,8 +5,8 @@ import sys import tempfile import unittest +import unittest.mock from pathlib import Path -from unittest import mock ROOT = Path(__file__).resolve().parents[2] @@ -56,7 +56,7 @@ def download_fixture( def test_current_templates_require_no_download_or_asset_root(self) -> None: with tempfile.TemporaryDirectory() as temporary: asset_root = Path(temporary) / "assets" - downloader = mock.Mock(side_effect=AssertionError) + downloader = unittest.mock.Mock(side_effect=AssertionError) versions = self.module.prepare_assets( ROOT / "release" / "exercises", asset_root, @@ -130,7 +130,7 @@ def test_missing_release_asset_is_rejected(self) -> None: ) def test_missing_github_cli_is_reported_without_command_output(self) -> None: - with mock.patch.object( + with unittest.mock.patch.object( self.module.subprocess, "run", side_effect=FileNotFoundError("gh missing"), diff --git a/release/scripts/test_validate_upgrade_exercise.py b/release/scripts/test_validate_upgrade_exercise.py index 03ceb1ee0..14139b516 100644 --- a/release/scripts/test_validate_upgrade_exercise.py +++ b/release/scripts/test_validate_upgrade_exercise.py @@ -141,6 +141,18 @@ def test_complete_candidate_record_is_valid(self) -> None: ), ) + 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_candidate_tag_must_resolve_to_exact_target_commit(self) -> None: record = self.candidate() tag_ref = f"refs/tags/{record['target_release']['version']}^{{commit}}" diff --git a/release/scripts/validate-upgrade-exercise.py b/release/scripts/validate-upgrade-exercise.py index c2141bac6..37ef22c2e 100644 --- a/release/scripts/validate-upgrade-exercise.py +++ b/release/scripts/validate-upgrade-exercise.py @@ -242,7 +242,11 @@ def validate_artifact_set(value: Any, record: dict[str, Any], *, template: bool) "relay_image": record["target_release"]["relay_image_digest"], "notary_image": record["target_release"]["notary_image_digest"], } - if any(artifacts[name] != digest for name, digest in expected.items()): + if any( + artifacts[name].removeprefix("sha256:") + != digest.removeprefix("sha256:") + for name, digest in expected.items() + ): raise ExerciseError("candidate_artifact_set does not match target release coordinates") if artifact_set["sha256"] != canonical_sha256(artifacts): raise ExerciseError("candidate_artifact_set.sha256 does not match its artifacts") From 1ec7430b1380ee104a31f2bd4a79093af6ac0451 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Fri, 24 Jul 2026 16:29:19 +0700 Subject: [PATCH 58/58] fix(release): normalize artifact set digests Signed-off-by: Jeremi Joslin --- release/scripts/test_validate_upgrade_exercise.py | 10 ++++++++++ release/scripts/validate-upgrade-exercise.py | 4 +++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/release/scripts/test_validate_upgrade_exercise.py b/release/scripts/test_validate_upgrade_exercise.py index 14139b516..caacf72eb 100644 --- a/release/scripts/test_validate_upgrade_exercise.py +++ b/release/scripts/test_validate_upgrade_exercise.py @@ -153,6 +153,16 @@ def test_artifact_coordinate_digest_prefixes_are_equivalent(self) -> None: 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_candidate_tag_must_resolve_to_exact_target_commit(self) -> None: record = self.candidate() tag_ref = f"refs/tags/{record['target_release']['version']}^{{commit}}" diff --git a/release/scripts/validate-upgrade-exercise.py b/release/scripts/validate-upgrade-exercise.py index 37ef22c2e..847837d57 100644 --- a/release/scripts/validate-upgrade-exercise.py +++ b/release/scripts/validate-upgrade-exercise.py @@ -248,7 +248,9 @@ def validate_artifact_set(value: Any, record: dict[str, Any], *, template: bool) for name, digest in expected.items() ): raise ExerciseError("candidate_artifact_set does not match target release coordinates") - if artifact_set["sha256"] != canonical_sha256(artifacts): + if artifact_set["sha256"].removeprefix( + "sha256:" + ) != canonical_sha256(artifacts).removeprefix("sha256:"): raise ExerciseError("candidate_artifact_set.sha256 does not match its artifacts")