From 558c3423c4cd20b73556653f80feb6a4a65dcbcc Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 11 Aug 2026 12:53:07 +0700 Subject: [PATCH 1/2] feat: add authoritative identifier catalog Signed-off-by: Jeremi Joslin --- .github/scripts/ci_changes.py | 55 ++ .github/scripts/test_ci_changes.py | 22 + .github/workflows/ci.yml | 28 + crates/registry-manifest-core/src/lib.rs | 4 - .../tests/metadata_core.rs | 6 +- .../examples/audit-event-schema.rs | 35 + .../examples/problem-catalog.rs | 66 ++ crates/registry-relay-v2/src/artifacts.rs | 4 +- crates/registry-relay-v2/src/problem.rs | 239 +++---- .../src/content/docs/reference/contracts.mdx | 34 +- products/identifiers/README.md | 95 +++ .../identifiers/contracts/catalog-source.json | 192 +++++ .../contracts/catalog.v1.schema.json | 84 +++ .../registry-relay/audit-event/v2alpha1.json | 209 ++++++ .../identifiers/generated/catalog.v1.json | 663 ++++++++++++++++++ products/identifiers/scripts/check.sh | 16 + products/identifiers/scripts/generate.py | 557 +++++++++++++++ products/identifiers/scripts/test_generate.py | 214 ++++++ products/manifest/CHANGELOG.md | 6 + release/OPERATIONS.md | 5 + release/scripts/registry-release | 85 +++ release/scripts/test_registry_release.py | 37 + .../scripts/test_registry_release_plans.py | 38 +- 23 files changed, 2526 insertions(+), 168 deletions(-) create mode 100644 crates/registry-relay-v2/examples/audit-event-schema.rs create mode 100644 crates/registry-relay-v2/examples/problem-catalog.rs create mode 100644 products/identifiers/README.md create mode 100644 products/identifiers/contracts/catalog-source.json create mode 100644 products/identifiers/contracts/catalog.v1.schema.json create mode 100644 products/identifiers/generated/artifacts/registry-relay/audit-event/v2alpha1.json create mode 100644 products/identifiers/generated/catalog.v1.json create mode 100755 products/identifiers/scripts/check.sh create mode 100755 products/identifiers/scripts/generate.py create mode 100644 products/identifiers/scripts/test_generate.py diff --git a/.github/scripts/ci_changes.py b/.github/scripts/ci_changes.py index 8b177031f..ac8159ec5 100644 --- a/.github/scripts/ci_changes.py +++ b/.github/scripts/ci_changes.py @@ -170,6 +170,52 @@ AUTHORING_REFERENCE_MANIFEST = ( REPO_ROOT / "docs/site/scripts/authoring-reference-sources.json" ) +IDENTIFIER_CATALOG_CONTRACT = ( + REPO_ROOT / "products/identifiers/contracts/catalog-source.json" +) + + +def identifier_catalog_inputs( + contract_path: Path = IDENTIFIER_CATALOG_CONTRACT, +) -> tuple[str, ...]: + """Derive every source path that can change the public identifier catalog.""" + + contract = json.loads(contract_path.read_text(encoding="utf-8")) + schema_groups = contract.get("schemaSources") + records = contract.get("records") + if not isinstance(schema_groups, list) or not isinstance(records, list): + raise ValueError( + f"identifier catalog has an invalid source contract: {contract_path}" + ) + + inputs = [ + "products/identifiers/**", + "crates/registry-relay-v2/src/problem.rs", + ] + for index, group in enumerate(schema_groups): + pattern = group.get("glob") if isinstance(group, dict) else None + if not isinstance(pattern, str) or not pattern: + raise ValueError(f"identifier schemaSources[{index}] has no glob") + inputs.append(pattern) + source = group.get("sourcePath") + if source is not None: + if not isinstance(source, str) or not source: + raise ValueError( + f"identifier schemaSources[{index}] has an invalid sourcePath" + ) + inputs.append(source) + for index, record in enumerate(records): + source = record.get("sourcePath") if isinstance(record, dict) else None + if not isinstance(source, str) or not source: + raise ValueError(f"identifier records[{index}] has no sourcePath") + inputs.append(source) + + if any(source.startswith(("/", "../")) for source in inputs): + raise ValueError("identifier catalog inputs must be repository-relative") + return tuple(dict.fromkeys(inputs)) + + +IDENTIFIER_CATALOG_INPUTS = identifier_catalog_inputs() def authoring_reference_contract_sources( @@ -405,6 +451,10 @@ def classify( seeds.update(PLATFORM_PACKAGES) elif path.startswith("products/relay-v2/"): seeds.update(RELAY_V2_PACKAGES) + elif path.startswith("products/identifiers/"): + # The catalog gate compiles its focused Relay V2 exporter. + # Catalog-only tooling does not require the full Rust matrix. + pass elif path in { "docs/site/src/data/generated/relay-support.json", "docs/site/src/data/relay-support.yaml", @@ -421,6 +471,10 @@ def classify( ) complete = run_all or force_all + identifiers = complete or any( + matches(path, *IDENTIFIER_CATALOG_INPUTS) for path in paths + ) + platform = complete or any( matches( path, @@ -644,6 +698,7 @@ def classify( "client_bindings": client_bindings, "registryctl_tutorial": registryctl_tutorial, "evidence_tutorial": evidence_tutorial, + "identifiers": identifiers, } diff --git a/.github/scripts/test_ci_changes.py b/.github/scripts/test_ci_changes.py index 10fd2f2c3..f9b7baaea 100644 --- a/.github/scripts/test_ci_changes.py +++ b/.github/scripts/test_ci_changes.py @@ -17,6 +17,7 @@ AUTHORING_REFERENCE_INPUTS, EVIDENCE_AUTHORING_GUIDE_IMPLEMENTATION_INPUTS, EVIDENCE_TUTORIAL_INPUTS, + IDENTIFIER_CATALOG_INPUTS, RELEASE_SECURITY_WORKFLOWS, SHARDS, Workspace, @@ -232,6 +233,27 @@ def test_relay_v2_paths_select_the_editor_and_reverse_dependents(self) -> None: ]: self.assertIn(package, outputs["rust_packages"]) + def test_every_identifier_source_selects_the_catalog_gate(self) -> None: + for pattern in IDENTIFIER_CATALOG_INPUTS: + sample = pattern.replace("**", "sample").replace("*", "sample") + with self.subTest(pattern=pattern): + self.assertTrue(classify(self.workspace, (sample,))["identifiers"]) + + def test_ci_always_checks_repository_identifier_reference_closure(self) -> None: + workflow = Path(".github/workflows/ci.yml").read_text(encoding="utf-8") + self.assertIn( + "products/identifiers/scripts/generate.py --check-references", + workflow, + ) + + def test_identifier_tooling_does_not_force_the_rust_matrix(self) -> None: + outputs = classify( + self.workspace, + ("products/identifiers/scripts/generate.py",), + ) + self.assertTrue(outputs["identifiers"]) + self.assertFalse(outputs["rust"]) + def test_relay_v2_product_material_selects_runtime_and_tooling(self) -> None: outputs = classify( self.workspace, diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f7247af9d..d9d71b494 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,6 +55,7 @@ jobs: client_bindings: ${{ steps.filter.outputs.client_bindings }} registryctl_tutorial: ${{ steps.filter.outputs.registryctl_tutorial }} evidence_tutorial: ${{ steps.filter.outputs.evidence_tutorial }} + identifiers: ${{ steps.filter.outputs.identifiers }} steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 @@ -99,6 +100,9 @@ jobs: fi "${classifier[@]}" + - name: Check repository identifier reference closure + run: products/identifiers/scripts/generate.py --check-references + - name: Test CI classifier run: python3 .github/scripts/test_ci_changes.py @@ -567,6 +571,29 @@ jobs: - name: Relay V2 coequal HTTP journeys run: products/relay-v2/scripts/test-http.sh + identifiers: + name: Public identifier catalog + needs: changes + if: needs.changes.outputs.identifiers == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + with: + persist-credentials: false + submodules: false + + - name: Cache Cargo registry + uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + shared-key: workspace-registry + cache-targets: false + save-if: ${{ github.ref == 'refs/heads/main' }} + + - name: Check public identifier catalog + run: products/identifiers/scripts/check.sh + rust-result: name: Rust workspace if: always() @@ -578,6 +605,7 @@ jobs: - evidence-contracts - relay-contracts - relay-v2-contracts + - identifiers runs-on: ubuntu-24.04 env: RUST_JOB_RESULTS: ${{ toJSON(needs) }} diff --git a/crates/registry-manifest-core/src/lib.rs b/crates/registry-manifest-core/src/lib.rs index c297bfae8..2bea03257 100644 --- a/crates/registry-manifest-core/src/lib.rs +++ b/crates/registry-manifest-core/src/lib.rs @@ -74,10 +74,6 @@ const BUILTIN_VOCABULARIES: &[(&str, &str)] = &[ "registry_manifest", "https://id.registrystack.org/ns/registry-manifest/v1#", ), - ( - "registry_relay", - "https://id.registrystack.org/ns/registry-relay/v1#", - ), ("sh", "http://www.w3.org/ns/shacl#"), ("skos", "http://www.w3.org/2004/02/skos/core#"), ("xsd", "http://www.w3.org/2001/XMLSchema#"), diff --git a/crates/registry-manifest-core/tests/metadata_core.rs b/crates/registry-manifest-core/tests/metadata_core.rs index bc29d5b07..15b43d049 100644 --- a/crates/registry-manifest-core/tests/metadata_core.rs +++ b/crates/registry-manifest-core/tests/metadata_core.rs @@ -984,10 +984,10 @@ fn vocabularies_protect_builtins_and_validate_custom_namespaces() { ); manifest.vocabularies.insert( "registry_relay".to_string(), - "https://id.registrystack.org/ns/registry-relay/v1#".to_string(), + "https://example.org/relay/ns#".to_string(), ); validate_manifest(&manifest) - .expect("safe custom vocabulary and identical protected values pass"); + .expect("safe custom vocabularies and identical protected values pass"); } #[test] @@ -2044,7 +2044,7 @@ datasets: lookup_keys: [national_id] access: kind: evidence-server - conforms_to: registry_relay:evidence-server-v1 + conforms_to: https://example.test/evidence-server/v1 endpoint_url: https://evidence.example.test discovery_url: https://evidence.example.test/.well-known/evidence-service ruleset: smallholder-v1 diff --git a/crates/registry-relay-v2/examples/audit-event-schema.rs b/crates/registry-relay-v2/examples/audit-event-schema.rs new file mode 100644 index 000000000..37b9e59c8 --- /dev/null +++ b/crates/registry-relay-v2/examples/audit-event-schema.rs @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: Apache-2.0 + +use std::{env, fs, path::PathBuf, process::ExitCode}; + +use registry_relay_v2::artifacts::audit_event_schema; + +fn main() -> ExitCode { + let mut arguments = env::args_os().skip(1); + let Some(flag) = arguments.next() else { + eprintln!("usage: audit-event-schema --output "); + return ExitCode::from(2); + }; + let Some(output) = arguments.next() else { + eprintln!("usage: audit-event-schema --output "); + return ExitCode::from(2); + }; + if flag != "--output" || arguments.next().is_some() { + eprintln!("usage: audit-event-schema --output "); + return ExitCode::from(2); + } + + let mut bytes = match serde_json::to_vec_pretty(&audit_event_schema()) { + Ok(bytes) => bytes, + Err(_) => { + eprintln!("audit event schema could not be serialized"); + return ExitCode::FAILURE; + } + }; + bytes.push(b'\n'); + if fs::write(PathBuf::from(output), bytes).is_err() { + eprintln!("audit event schema could not be written"); + return ExitCode::FAILURE; + } + ExitCode::SUCCESS +} diff --git a/crates/registry-relay-v2/examples/problem-catalog.rs b/crates/registry-relay-v2/examples/problem-catalog.rs new file mode 100644 index 000000000..86da1b619 --- /dev/null +++ b/crates/registry-relay-v2/examples/problem-catalog.rs @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: Apache-2.0 + +use std::{env, fs, path::PathBuf, process::ExitCode}; + +use registry_relay_v2::problem::ProblemCode; +use serde::Serialize; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ProblemCatalog<'a> { + entries: Vec>, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ProblemEntry<'a> { + uri: String, + code: &'a str, + title: &'a str, + description: &'a str, + http_statuses: [u16; 1], +} + +fn main() -> ExitCode { + let mut arguments = env::args_os().skip(1); + let Some(flag) = arguments.next() else { + eprintln!("usage: problem-catalog --output "); + return ExitCode::from(2); + }; + let Some(output) = arguments.next() else { + eprintln!("usage: problem-catalog --output "); + return ExitCode::from(2); + }; + if flag != "--output" || arguments.next().is_some() { + eprintln!("usage: problem-catalog --output "); + return ExitCode::from(2); + } + + let catalog = ProblemCatalog { + entries: ProblemCode::ALL + .iter() + .copied() + .map(|problem| ProblemEntry { + uri: problem.type_uri(), + code: problem.code(), + title: problem.title(), + description: problem.detail(), + http_statuses: [problem.status()], + }) + .collect(), + }; + let mut bytes = match serde_json::to_vec_pretty(&catalog) { + Ok(bytes) => bytes, + Err(_) => { + eprintln!("problem catalog could not be serialized"); + return ExitCode::FAILURE; + } + }; + bytes.push(b'\n'); + let output = PathBuf::from(output); + if fs::write(output, bytes).is_err() { + eprintln!("problem catalog could not be written"); + return ExitCode::FAILURE; + } + ExitCode::SUCCESS +} diff --git a/crates/registry-relay-v2/src/artifacts.rs b/crates/registry-relay-v2/src/artifacts.rs index ae481494d..cfe3689e5 100644 --- a/crates/registry-relay-v2/src/artifacts.rs +++ b/crates/registry-relay-v2/src/artifacts.rs @@ -1683,7 +1683,9 @@ fn absolute(base: &str, path: &str) -> String { format!("{}{path}", base.trim_end_matches('/')) } -fn audit_event_schema() -> Value { +/// Return the fixed value-free audit event JSON Schema published in packages. +#[must_use] +pub fn audit_event_schema() -> Value { json!({ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://id.registrystack.org/schemas/registry-relay/audit-event/v2alpha1", diff --git a/crates/registry-relay-v2/src/problem.rs b/crates/registry-relay-v2/src/problem.rs index 417bcedec..842e331a6 100644 --- a/crates/registry-relay-v2/src/problem.rs +++ b/crates/registry-relay-v2/src/problem.rs @@ -13,129 +13,86 @@ use ulid::Ulid; const PROBLEM_BASE: &str = "https://id.registrystack.org/problems/registry-relay/"; -/// Closed public failure classes for the V2 HTTP boundary. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum ProblemCode { - ConsultationInvalidRequest, - AggregateDataInvalidRequest, - FieldsInvalid, - UnknownFilter, - InvalidFilter, - CursorInvalid, - AccessProfileInvalid, - MissingCredential, - InvalidCredential, - ConsultationDenied, - AggregateDataDenied, - ResourceNotFound, - ConsultationUnresolved, - UnsupportedFormat, - BodyTooLarge, - ConsultationResponseTooLarge, - AggregateDataTooLarge, - UriTooLong, - UnsupportedMediaType, - RateLimited, - AggregateDataRateLimited, - Internal, - SourceUnavailable, - AuditUnavailable, - ServiceNotReady, - Timeout, -} - -impl ProblemCode { - #[must_use] - pub const fn code(self) -> &'static str { - match self { - Self::ConsultationInvalidRequest => "consultation.invalid_request", - Self::AggregateDataInvalidRequest => "aggregate-data.invalid_request", - Self::FieldsInvalid => "request.fields_invalid", - Self::UnknownFilter => "filter.unknown_field", - Self::InvalidFilter => "filter.invalid_value", - Self::CursorInvalid => "query.cursor_invalid", - Self::AccessProfileInvalid => "request.access_profile_invalid", - Self::MissingCredential => "auth.missing_credential", - Self::InvalidCredential => "auth.invalid_credential", - Self::ConsultationDenied => "consultation.denied", - Self::AggregateDataDenied => "aggregate-data.denied", - Self::ResourceNotFound => "resource.not_found", - Self::ConsultationUnresolved => "consultation.unresolved", - Self::UnsupportedFormat => "format.unsupported", - Self::BodyTooLarge => "internal.payload_too_large", - Self::ConsultationResponseTooLarge => "consultation.response_too_large", - Self::AggregateDataTooLarge => "aggregate-data.too_large", - Self::UriTooLong => "internal.uri_too_long", - Self::UnsupportedMediaType => "request.media_type_unsupported", - Self::RateLimited => "consultation.rate_limited", - Self::AggregateDataRateLimited => "aggregate-data.rate_limited", - Self::SourceUnavailable => "source.unavailable", - Self::AuditUnavailable => "audit.unavailable", - Self::Internal => "internal.unhandled", - Self::ServiceNotReady => "service.not_ready", - Self::Timeout => "internal.timeout", +macro_rules! define_problem_codes { + ($( + $variant:ident => { + code: $code:literal, + title: $title:literal, + status: $status:literal, + detail: $detail:literal } - } - - #[must_use] - pub const fn title(self) -> &'static str { - match self { - Self::ConsultationInvalidRequest => "Consultation request is invalid", - Self::AggregateDataInvalidRequest => "Aggregate data request is invalid", - Self::FieldsInvalid => "Field selection is invalid", - Self::UnknownFilter => "Filter is not declared", - Self::InvalidFilter => "Filter value is invalid", - Self::CursorInvalid => "Cursor is invalid", - Self::AccessProfileInvalid => "Access profile selection is invalid", - Self::MissingCredential => "Bearer access token is required", - Self::InvalidCredential => "Bearer access token is invalid", - Self::ConsultationDenied => "Consultation is not permitted", - Self::AggregateDataDenied => "Aggregate data access is not permitted", - Self::ResourceNotFound => "Requested resource was not found", - Self::ConsultationUnresolved => "Requested record was not resolved", - Self::UnsupportedFormat => "Requested format is not supported", - Self::BodyTooLarge => "Request body is too large", - Self::ConsultationResponseTooLarge => "Consultation response is too large", - Self::AggregateDataTooLarge => "Aggregate data request is too broad", - Self::UriTooLong => "Request URI is too long", - Self::UnsupportedMediaType => "Request media type is not supported", - Self::RateLimited => "Consultation quota is exhausted", - Self::AggregateDataRateLimited => "Aggregate data quota is exhausted", - Self::Internal => "Request could not be served", - Self::SourceUnavailable => "Authoritative source is unavailable", - Self::AuditUnavailable => "Required audit is unavailable", - Self::ServiceNotReady => "Service is not ready", - Self::Timeout => "Request timed out", + ),+ $(,)?) => { + /// Closed public failure classes for the V2 HTTP boundary. + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + pub enum ProblemCode { + $($variant),+ } - } - #[must_use] - pub const fn status(self) -> u16 { - match self { - Self::ConsultationInvalidRequest - | Self::AggregateDataInvalidRequest - | Self::FieldsInvalid - | Self::UnknownFilter - | Self::InvalidFilter - | Self::CursorInvalid - | Self::AccessProfileInvalid => 400, - Self::MissingCredential | Self::InvalidCredential => 401, - Self::ConsultationDenied | Self::AggregateDataDenied => 403, - Self::ResourceNotFound | Self::ConsultationUnresolved => 404, - Self::UnsupportedFormat => 406, - Self::BodyTooLarge - | Self::ConsultationResponseTooLarge - | Self::AggregateDataTooLarge => 413, - Self::UriTooLong => 414, - Self::UnsupportedMediaType => 415, - Self::RateLimited | Self::AggregateDataRateLimited => 429, - Self::SourceUnavailable | Self::AuditUnavailable => 503, - Self::ServiceNotReady => 503, - Self::Timeout => 504, - Self::Internal => 500, + impl ProblemCode { + /// Complete inventory used to generate the public identifier catalog. + pub const ALL: &'static [Self] = &[$(Self::$variant),+]; + + #[must_use] + pub const fn code(self) -> &'static str { + match self { + $(Self::$variant => $code),+ + } + } + + #[must_use] + pub const fn title(self) -> &'static str { + match self { + $(Self::$variant => $title),+ + } + } + + #[must_use] + pub const fn status(self) -> u16 { + match self { + $(Self::$variant => $status),+ + } + } + + #[must_use] + pub const fn detail(self) -> &'static str { + match self { + $(Self::$variant => $detail),+ + } + } } - } + }; +} + +define_problem_codes! { + ConsultationInvalidRequest => { code: "consultation.invalid_request", title: "Consultation request is invalid", status: 400, detail: "the consultation request is invalid" }, + AggregateDataInvalidRequest => { code: "aggregate-data.invalid_request", title: "Aggregate data request is invalid", status: 400, detail: "the aggregate data request is invalid" }, + FieldsInvalid => { code: "request.fields_invalid", title: "Field selection is invalid", status: 400, detail: "field selection is invalid" }, + UnknownFilter => { code: "filter.unknown_field", title: "Filter is not declared", status: 400, detail: "filter is not declared for this operation" }, + InvalidFilter => { code: "filter.invalid_value", title: "Filter value is invalid", status: 400, detail: "filter value is invalid" }, + CursorInvalid => { code: "query.cursor_invalid", title: "Cursor is invalid", status: 400, detail: "cursor is invalid for this query" }, + AccessProfileInvalid => { code: "request.access_profile_invalid", title: "Access profile selection is invalid", status: 400, detail: "access profile selection is invalid" }, + MissingCredential => { code: "auth.missing_credential", title: "Bearer access token is required", status: 401, detail: "a bearer access token is required" }, + InvalidCredential => { code: "auth.invalid_credential", title: "Bearer access token is invalid", status: 401, detail: "bearer access token validation failed" }, + ConsultationDenied => { code: "consultation.denied", title: "Consultation is not permitted", status: 403, detail: "the consultation is not permitted" }, + AggregateDataDenied => { code: "aggregate-data.denied", title: "Aggregate data access is not permitted", status: 403, detail: "aggregate data access is not permitted" }, + ResourceNotFound => { code: "resource.not_found", title: "Requested resource was not found", status: 404, detail: "the requested resource was not found" }, + ConsultationUnresolved => { code: "consultation.unresolved", title: "Requested record was not resolved", status: 404, detail: "the requested record was not resolved" }, + UnsupportedFormat => { code: "format.unsupported", title: "Requested format is not supported", status: 406, detail: "the requested format is not supported" }, + BodyTooLarge => { code: "internal.payload_too_large", title: "Request body is too large", status: 413, detail: "request body exceeds the configured limit" }, + ConsultationResponseTooLarge => { code: "consultation.response_too_large", title: "Consultation response is too large", status: 413, detail: "the consultation response exceeds the configured limit" }, + AggregateDataTooLarge => { code: "aggregate-data.too_large", title: "Aggregate data request is too broad", status: 413, detail: "the aggregate data request exceeds its observation limit" }, + UriTooLong => { code: "internal.uri_too_long", title: "Request URI is too long", status: 414, detail: "request URI exceeds the configured limit" }, + UnsupportedMediaType => { code: "request.media_type_unsupported", title: "Request media type is not supported", status: 415, detail: "request body must use application/json" }, + RateLimited => { code: "consultation.rate_limited", title: "Consultation quota is exhausted", status: 429, detail: "the consultation quota is exhausted" }, + AggregateDataRateLimited => { code: "aggregate-data.rate_limited", title: "Aggregate data quota is exhausted", status: 429, detail: "the aggregate data quota is exhausted" }, + Internal => { code: "internal.unhandled", title: "Request could not be served", status: 500, detail: "the request could not be served" }, + SourceUnavailable => { code: "source.unavailable", title: "Authoritative source is unavailable", status: 503, detail: "the authoritative source is unavailable" }, + AuditUnavailable => { code: "audit.unavailable", title: "Required audit is unavailable", status: 503, detail: "required audit is unavailable" }, + ServiceNotReady => { code: "service.not_ready", title: "Service is not ready", status: 503, detail: "the service is not ready" }, + Timeout => { code: "internal.timeout", title: "Request timed out", status: 504, detail: "request exceeded the configured timeout" }, +} +impl ProblemCode { #[must_use] pub fn type_uri(self) -> String { format!("{PROBLEM_BASE}{}", self.code().replace('.', "/")) @@ -180,41 +137,6 @@ impl ProblemCode { trace.apply(headers); response } - - const fn detail(self) -> &'static str { - match self { - Self::ConsultationInvalidRequest => "the consultation request is invalid", - Self::AggregateDataInvalidRequest => "the aggregate data request is invalid", - Self::FieldsInvalid => "field selection is invalid", - Self::UnknownFilter => "filter is not declared for this operation", - Self::InvalidFilter => "filter value is invalid", - Self::CursorInvalid => "cursor is invalid for this query", - Self::AccessProfileInvalid => "access profile selection is invalid", - Self::MissingCredential => "a bearer access token is required", - Self::InvalidCredential => "bearer access token validation failed", - Self::ConsultationDenied => "the consultation is not permitted", - Self::AggregateDataDenied => "aggregate data access is not permitted", - Self::ResourceNotFound => "the requested resource was not found", - Self::ConsultationUnresolved => "the requested record was not resolved", - Self::UnsupportedFormat => "the requested format is not supported", - Self::BodyTooLarge => "request body exceeds the configured limit", - Self::ConsultationResponseTooLarge => { - "the consultation response exceeds the configured limit" - } - Self::AggregateDataTooLarge => { - "the aggregate data request exceeds its observation limit" - } - Self::UriTooLong => "request URI exceeds the configured limit", - Self::UnsupportedMediaType => "request body must use application/json", - Self::RateLimited => "the consultation quota is exhausted", - Self::AggregateDataRateLimited => "the aggregate data quota is exhausted", - Self::Internal => "the request could not be served", - Self::SourceUnavailable => "the authoritative source is unavailable", - Self::AuditUnavailable => "required audit is unavailable", - Self::ServiceNotReady => "the service is not ready", - Self::Timeout => "request exceeded the configured timeout", - } - } } /// A validated W3C Trace Context trace identifier. @@ -341,6 +263,17 @@ pub struct ProblemBody { #[cfg(test)] mod tests { use super::*; + use std::collections::BTreeSet; + + #[test] + fn public_problem_inventory_has_unique_codes_and_type_uris() { + let mut codes = BTreeSet::new(); + let mut type_uris = BTreeSet::new(); + for problem in ProblemCode::ALL { + assert!(codes.insert(problem.code())); + assert!(type_uris.insert(problem.type_uri())); + } + } #[test] fn unresolved_lookup_causes_have_one_public_body() { diff --git a/docs/site/src/content/docs/reference/contracts.mdx b/docs/site/src/content/docs/reference/contracts.mdx index 84735589d..05441390b 100644 --- a/docs/site/src/content/docs/reference/contracts.mdx +++ b/docs/site/src/content/docs/reference/contracts.mdx @@ -64,7 +64,25 @@ The table is generated from `src/data/contracts.yaml`. ## Machine identifiers -Stable machine identifiers for the registry stack resolve at `https://id.registrystack.org/`: RFC 9457 problem types, JSON-LD namespaces, JSON Schemas, and JSON-LD contexts. The resolver identifies each one and links back to the owning documentation. The running service response and the artifacts below remain authoritative for runtime behavior. +Stable machine identifiers for the registry stack resolve at `https://id.registrystack.org/`: +RFC 9457 problem types, JSON-LD namespaces and vocabulary terms, and JSON Schemas. +The generated `products/identifiers/generated/catalog.v1.json` file in `registry-stack` is the +source of truth for identifiers owned by current source. + +The generator derives that catalog from the closed Relay V2 problem inventory, declared current +JSON Schema groups, and an explicit namespace and vocabulary inventory. +It also classifies repository references that are fixtures, external demo values, or retired +source so an unclassified `id.registrystack.org` reference fails generation. +Each entry records its owner, active status, compatibility line, source path, and source SHA-256 digest. +Schema entries also bind the published artifact path and artifact SHA-256 digest. + +The `registrystack-id` publisher imports a catalog from one exact `registry-stack` commit and +catalog digest. It publishes exactly that active catalog. An identifier removed from current +source no longer resolves, and the publisher refuses to reuse the URI with another identifier kind. + +The resolver describes identifiers and serves their artifacts. Runtime services do not fetch the +resolver to make authentication, authorization, or disclosure decisions. The running service +response and its owning source remain authoritative for runtime behavior. ### Credential contract ownership @@ -77,15 +95,19 @@ A client that needs a signed, minimum-disclosure answer calls Evidence Gateway d ### JSON-LD namespaces -The registry stack defines JSON-LD vocabularies for its metadata terms: +The identifier catalog records these JSON-LD namespaces and vocabularies: -- `registry-relay/v1`: Registry Relay metadata terms, used in emitted DCAT-AP catalogs. -- `registry-manifest/v1`: Registry Manifest terms. +- `registry-manifest/v1`: Active Registry Manifest terms. +- `vocab/core` and `vocab/handling`: Active Registry Relay V2 vocabulary bases and terms. -Their canonical home is `id.registrystack.org/ns/registry-relay/...` and `id.registrystack.org/ns/registry-manifest/...`. +Child identifiers under `vocab/core/` are adopter-defined semantic predicates. +Resolving one of those child identifiers identifies the Registry-owned vocabulary base; it does +not register or review the adopter-defined term. + +Their canonical home is `id.registrystack.org/ns/...` or `id.registrystack.org/vocab/...`, as +recorded in the generated catalog. ## Known gaps - The Registry Relay OpenAPI artifact is a `hand-authored-abstract-contract`; see [API references provenance and freshness](../apis/) for detail on what that means and how to get an instance-specific shape. - Contract freshness checks are data-backed in v0 but are not yet generated from owning-repo release metadata. -- Pinned artifacts are regenerated and reviewed manually; there is no automated provenance check. diff --git a/products/identifiers/README.md b/products/identifiers/README.md new file mode 100644 index 000000000..40aa67460 --- /dev/null +++ b/products/identifiers/README.md @@ -0,0 +1,95 @@ +# Registry Stack identifier catalog + +This product material is the source contract for identifiers under +`https://id.registrystack.org/`. The resolver documents public identifiers; it +is not an identity provider, token issuer, authorization service, runtime +configuration source, or trust anchor. + +The generated active-only catalog closes three maintained surfaces: + +- Relay V2 problem types come from `ProblemCode::ALL` and preserve the exact + public code, title, status, and value-free detail. +- Public JSON Schemas come from the `$id` values in the source groups declared + by `contracts/catalog-source.json`; their exact bytes are SHA-256 bound. +- Namespace and vocabulary records come from the explicit entries in the same + source file and are SHA-256 bound to the source that defines them. + +`generated/catalog.v1.json` is deterministic. Regenerate it with: + +```bash +products/identifiers/scripts/generate.py --write +``` + +Check the committed catalog, schema closure, and generator tests with: + +```bash +products/identifiers/scripts/check.sh +``` + +## Publication contract + +The publisher imports this catalog from one exact Registry Stack commit. It +copies artifact bytes only after checking their recorded digest and publishes +exactly the identifiers in that catalog. An identifier removed from current +source is removed from the resolver. A current entry may update the metadata +for the same identifier, but neither the catalog nor the publisher may change +an identifier's kind or reuse it for a different meaning. + +The canonical schema URI may identify the current schema within its named +compatibility line. The publisher also exposes the exact imported bytes by +SHA-256 digest so a release or adopter can retain an immutable reference. + +Live resolver availability is verified after publication. It is not a runtime +authentication dependency and an external network probe is not a source-build +or release blocker. + +## Security review + +- Threat: a removed, repurposed, or mismatched identifier can misdocument an + authorization failure or give tooling schema bytes that do not match the + reviewed source. +- Enforcement point: the Relay inventory, catalog generator, source and + artifact digests, exact publisher import, and publisher build check. +- Negative case: generation fails when a tracked public schema is outside the + closed source groups, a tracked repository identifier reference is neither + active nor in the reviewed exclusion inventory, an exclusion matches no + tracked file, an artifact `$id` differs from its catalog URI, an identifier + is duplicated, or generated bytes drift from the committed catalog. +- Trust boundary: identifier metadata describes existing product behavior. It + creates no principal, claim, permission, disclosure rule, credential, or + signing authority. +- Recovery: fix forward with a reviewed catalog and redeploy. A removed URI may + remain absent, but it must never be reused or repurposed. + +## Definition of Done + +The identifier publication repair is complete when all of the following are +true: + +- Registry Stack generates one catalog from the complete Relay V2 problem + inventory, the declared public schema groups, and explicit namespace and + vocabulary records. +- Every catalog artifact records its source path, source digest, media type, + and exact artifact digest. +- Relay V2 problem generation preserves its closed value-free problem facts + and adds no authentication or disclosure behavior. +- Retired Relay V1, Registry Notary, Registry Platform operations, registryctl, + and release-lock identifiers are absent from the generated catalog and + resolver. +- Registry-owned Relay V2 vocabulary identifiers are published; Solmara demo + identifiers and legacy SHACL fixture identifiers remain outside the public + catalog because they are not Registry Stack contracts. +- Registry Stack CI checks tracked identifier-reference closure on every + change and selects the full identifier contract for every owning problem, + schema, namespace, vocabulary, generator, and catalog input. +- Future release manifests from version `0.19.1` bind the catalog path, digest, + and entry count without making live resolver availability a release gate. +- The publisher imports an exact Registry Stack commit, verifies every digest, + publishes exactly the active catalog, generates the complete static site, + and has pull-request validation, automated source synchronization, and a + scheduled live smoke check. +- Focused Rust, generator, CI-routing, documentation, publisher, and + cross-repository checks pass with no unrelated changes. +- Separate draft pull requests exist for `registry-stack` and + `registrystack-id`, with the publisher PR declaring its exact dependency on + the Registry Stack source commit. diff --git a/products/identifiers/contracts/catalog-source.json b/products/identifiers/contracts/catalog-source.json new file mode 100644 index 000000000..75cb96ca8 --- /dev/null +++ b/products/identifiers/contracts/catalog-source.json @@ -0,0 +1,192 @@ +{ + "version": 1, + "baseUrl": "https://id.registrystack.org", + "referenceExclusions": [ + { + "glob": "crates/registry-config-report/**", + "classification": "legacy-source", + "reason": "Configuration-report schemas are not current public Registry Stack identifier contracts." + }, + { + "glob": "crates/registry-manifest-core/tests/**", + "classification": "fixture", + "reason": "Manifest test shapes are local conformance fixtures, not Registry Stack identifier contracts." + }, + { + "glob": "crates/registry-platform-httpsec/**", + "classification": "legacy-source", + "reason": "The legacy body-limit problem helper is not wired into a current product route." + }, + { + "glob": "crates/registry-platform-ops/**", + "classification": "legacy-source", + "reason": "Legacy operations schemas remain source-local and are not current resolver contracts." + }, + { + "glob": "crates/registry-platform-testing/**", + "classification": "fixture", + "reason": "Cross-crate HTTP problem values are test fixtures for legacy helpers." + }, + { + "glob": "crates/registry-relay/**", + "classification": "retired-product", + "reason": "Registry Relay V1 is retired and its identifiers are not published by the current resolver." + }, + { + "glob": "crates/registryctl/**", + "classification": "retired-product", + "reason": "registryctl is retired and its identifiers are not published by the current resolver." + }, + { + "glob": "docs/site/public/generated/configuration-reference*.json", + "classification": "legacy-generated-docs", + "reason": "Generated registryctl reference artifacts retain source-local schema identifiers." + }, + { + "glob": "docs/site/scripts/generate-authoring-reference*.mjs", + "classification": "legacy-generated-docs", + "reason": "The authoring reference generator retains registryctl schema identifiers." + }, + { + "glob": "docs/site/scripts/ops-posture-spec.test.mjs", + "classification": "legacy-generated-docs", + "reason": "The operations posture test binds a source-local legacy schema identifier." + }, + { + "glob": "docs/site/src/content/docs/reference/api-stability.mdx", + "classification": "historical-documentation", + "reason": "The stability reference describes the broader historical identifier surface." + }, + { + "glob": "docs/site/src/content/docs/spec/rs-op-posture.mdx", + "classification": "historical-documentation", + "reason": "The posture specification records a source-local legacy schema identifier." + }, + { + "glob": "docs/site/src/content/docs/tutorials/first-run-with-solmara-lab.mdx", + "classification": "external-demo", + "reason": "Solmara Lab identifiers are adopter-owned demo values, not Registry Stack contracts." + }, + { + "glob": "docs/site/src/data/generated/configuration-reference*.json", + "classification": "legacy-generated-docs", + "reason": "Generated registryctl reference data retains source-local schema identifiers." + }, + { + "glob": "products/manifest/fixtures/**", + "classification": "fixture", + "reason": "Manifest profile identifiers in fixtures are not Registry Stack resolver contracts." + }, + { + "glob": "release/*.schema.json", + "classification": "retired-product", + "reason": "RegistryReleaseLockV1 identifiers belong to retired registryctl release tooling." + }, + { + "glob": "release/scripts/registry_release_lock.py", + "classification": "retired-product", + "reason": "RegistryReleaseLockV1 identifiers belong to retired registryctl release tooling." + }, + { + "glob": "schemas/*.schema.json", + "classification": "retired-product", + "reason": "Root Relay V1 and Notary configuration schemas are retired." + } + ], + "schemaSources": [ + { + "glob": "products/identifiers/generated/artifacts/**/*.json", + "sourcePath": "crates/registry-relay-v2/src/artifacts.rs", + "owner": "relay-v2", + "status": "active", + "compatibilityLine": "v2alpha1", + "description": "Relay V2 generated public JSON Schema." + }, + { + "glob": "products/identifiers/contracts/*.schema.json", + "owner": "identifiers", + "status": "active", + "compatibilityLine": "v1", + "description": "JSON Schema for the Registry Stack identifier catalog." + }, + { + "glob": "crates/registry-relayctl/schemas/authoring/*.json", + "owner": "relay-v2", + "status": "active", + "compatibilityLine": "v2alpha1", + "description": "Relay V2 authoring JSON Schema." + } + ], + "records": [ + { + "uri": "https://id.registrystack.org/ns/registry-manifest/v1#", + "kind": "namespace", + "status": "active", + "compatibilityLine": "v1", + "owner": "registry-manifest", + "title": "Registry Manifest v1 namespace", + "description": "JSON-LD namespace for Registry Manifest terms.", + "sourcePath": "crates/registry-manifest-core/src/lib.rs" + }, + { + "uri": "https://id.registrystack.org/vocab/core/", + "kind": "vocabulary", + "status": "active", + "compatibilityLine": "relay-v2", + "owner": "relay-v2", + "title": "Registry Relay core vocabulary", + "description": "Vocabulary base for Relay V2 Registry Core semantic predicates.", + "sourcePath": "crates/registry-relay-v2/src/semantics.rs" + }, + { + "uri": "https://id.registrystack.org/vocab/sourceRequired", + "kind": "vocabulary-term", + "status": "active", + "compatibilityLine": "relay-v2", + "owner": "relay-v2", + "title": "Source required", + "description": "Relay V2 Registry Core predicate indicating whether a source value is required.", + "sourcePath": "crates/registry-relay-v2/src/semantics.rs" + }, + { + "uri": "https://id.registrystack.org/vocab/codelist", + "kind": "vocabulary-term", + "status": "active", + "compatibilityLine": "relay-v2", + "owner": "relay-v2", + "title": "Codelist", + "description": "Relay V2 Registry Core predicate binding a controlled property to its codelist.", + "sourcePath": "crates/registry-relay-v2/src/semantics.rs" + }, + { + "uri": "https://id.registrystack.org/vocab/geometryType", + "kind": "vocabulary-term", + "status": "active", + "compatibilityLine": "relay-v2", + "owner": "relay-v2", + "title": "Geometry type", + "description": "Relay V2 Registry Core predicate identifying a spatial property's geometry type.", + "sourcePath": "crates/registry-relay-v2/src/semantics.rs" + }, + { + "uri": "https://id.registrystack.org/vocab/coordinateReferenceSystem", + "kind": "vocabulary-term", + "status": "active", + "compatibilityLine": "relay-v2", + "owner": "relay-v2", + "title": "Coordinate reference system", + "description": "Relay V2 Registry Core predicate identifying a spatial property's coordinate reference system.", + "sourcePath": "crates/registry-relay-v2/src/semantics.rs" + }, + { + "uri": "https://id.registrystack.org/vocab/handling", + "kind": "vocabulary", + "status": "active", + "compatibilityLine": "relay-v2", + "owner": "relay-v2", + "title": "Registry Relay handling vocabulary", + "description": "Ordered Relay V2 technical handling levels: public, internal, confidential, and restricted.", + "sourcePath": "products/relay-v2/CONCEPT.md" + } + ] +} diff --git a/products/identifiers/contracts/catalog.v1.schema.json b/products/identifiers/contracts/catalog.v1.schema.json new file mode 100644 index 000000000..159a904c6 --- /dev/null +++ b/products/identifiers/contracts/catalog.v1.schema.json @@ -0,0 +1,84 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://id.registrystack.org/schemas/identifiers/catalog.v1.schema.json", + "title": "Registry Stack identifier catalog v1", + "type": "object", + "additionalProperties": false, + "required": ["version", "baseUrl", "entries"], + "properties": { + "version": { "const": 1 }, + "baseUrl": { "const": "https://id.registrystack.org" }, + "entries": { + "type": "array", + "items": { "$ref": "#/$defs/entry" } + } + }, + "$defs": { + "sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "source": { + "type": "object", + "additionalProperties": false, + "required": ["path", "sha256"], + "properties": { + "path": { "type": "string", "minLength": 1 }, + "sha256": { "$ref": "#/$defs/sha256" } + } + }, + "artifact": { + "type": "object", + "additionalProperties": false, + "required": ["path", "sha256", "mediaType"], + "properties": { + "path": { "type": "string", "minLength": 1 }, + "sha256": { "$ref": "#/$defs/sha256" }, + "mediaType": { "type": "string", "minLength": 1 } + } + }, + "problem": { + "type": "object", + "additionalProperties": false, + "required": ["code", "httpStatuses"], + "properties": { + "code": { "type": "string", "minLength": 1 }, + "httpStatuses": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "type": "integer", "minimum": 100, "maximum": 599 } + } + } + }, + "entry": { + "type": "object", + "additionalProperties": false, + "required": ["uri", "kind", "status", "compatibilityLine", "owner", "title", "description", "source"], + "properties": { + "uri": { "type": "string", "pattern": "^https://id\\.registrystack\\.org/" }, + "kind": { + "enum": ["problem", "schema", "context", "namespace", "vocabulary", "vocabulary-term"] + }, + "status": { "const": "active" }, + "compatibilityLine": { "type": "string", "minLength": 1 }, + "owner": { "type": "string", "minLength": 1 }, + "title": { "type": "string", "minLength": 1 }, + "description": { "type": "string", "minLength": 1 }, + "source": { "$ref": "#/$defs/source" }, + "artifact": { "$ref": "#/$defs/artifact" }, + "problem": { "$ref": "#/$defs/problem" } + }, + "allOf": [ + { + "if": { "properties": { "kind": { "const": "problem" } }, "required": ["kind"] }, + "then": { "required": ["problem"] } + }, + { + "if": { "properties": { "kind": { "enum": ["schema", "context"] } }, "required": ["kind"] }, + "then": { "required": ["artifact"] } + } + ] + } + } +} diff --git a/products/identifiers/generated/artifacts/registry-relay/audit-event/v2alpha1.json b/products/identifiers/generated/artifacts/registry-relay/audit-event/v2alpha1.json new file mode 100644 index 000000000..9c1502267 --- /dev/null +++ b/products/identifiers/generated/artifacts/registry-relay/audit-event/v2alpha1.json @@ -0,0 +1,209 @@ +{ + "$id": "https://id.registrystack.org/schemas/registry-relay/audit-event/v2alpha1", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "accessProfile": { + "minLength": 1, + "type": "string" + }, + "accessRuleRevision": { + "minLength": 1, + "type": "string" + }, + "contractRevision": { + "pattern": "^sha256:[0-9a-f]{64}$", + "type": "string" + }, + "disclosureHandling": { + "enum": [ + "public", + "internal", + "confidential", + "restricted" + ] + }, + "disclosureProfile": { + "minLength": 1, + "type": "string" + }, + "formatProfile": { + "enum": [ + "rfc7946", + "jsonfg" + ] + }, + "operationId": { + "minLength": 1, + "type": "string" + }, + "operationIdentifier": { + "minLength": 1, + "type": "string" + }, + "operationSurface": { + "enum": [ + "record-list", + "record-read", + "record-lookup", + "record-search", + "sdmx-data", + "sdmx-dataflow-structure", + "sdmx-datastructure-structure", + "unknown" + ] + }, + "outcome": { + "enum": [ + "released", + "not-modified", + "unresolved", + "invalid-request", + "missing-credential", + "invalid-credential", + "denied", + "rate-limited", + "timed-out", + "source-failed", + "internal-failed", + "not-found" + ] + }, + "phase": { + "enum": [ + "attempt", + "refusal", + "terminal" + ] + }, + "principalKind": { + "enum": [ + "anonymous", + "authenticated", + "unknown" + ] + }, + "processingDescriptionIdentifiers": { + "items": { + "minLength": 1, + "type": "string" + }, + "type": "array", + "uniqueItems": true + }, + "processingHandling": { + "enum": [ + "public", + "internal", + "confidential", + "restricted" + ] + }, + "purpose": { + "minLength": 1, + "type": "string" + }, + "queryShape": { + "enum": [ + "sdmx-keyed-time-period", + "sdmx-keyed-all-dimensions", + "sdmx-omitted-key-time-period", + "sdmx-omitted-key-all-dimensions" + ] + }, + "registryIdentifier": { + "minLength": 1, + "type": "string" + }, + "resourceIdentifier": { + "minLength": 1, + "type": "string" + }, + "rowBoundaryKind": { + "enum": [ + "none", + "principal", + "verified-claim", + "unknown" + ] + }, + "schema": { + "const": "registry.relay.audit/v2alpha1" + }, + "selectedProperties": { + "items": { + "minLength": 1, + "type": "string" + }, + "type": "array", + "uniqueItems": true + }, + "sourceRevision": { + "additionalProperties": false, + "properties": { + "profile": { + "enum": [ + "snapshot", + "live" + ] + }, + "status": { + "enum": [ + "versioned", + "unversioned" + ] + }, + "value": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "profile", + "status", + "value" + ], + "type": "object" + }, + "traceId": { + "pattern": "^[0-9a-f]{32}$", + "type": "string" + }, + "transformIdentifiers": { + "items": { + "minLength": 1, + "type": "string" + }, + "type": "array", + "uniqueItems": true + }, + "wireFormat": { + "enum": [ + "json", + "json-ld", + "geojson", + "sdmx-json", + "sdmx-csv", + "sdmx-structure-json" + ] + } + }, + "required": [ + "schema", + "phase", + "operationId", + "traceId", + "registryIdentifier", + "operationSurface", + "rowBoundaryKind", + "processingDescriptionIdentifiers", + "selectedProperties", + "transformIdentifiers", + "contractRevision", + "principalKind" + ], + "title": "Registry Relay value-free audit event", + "type": "object" +} diff --git a/products/identifiers/generated/catalog.v1.json b/products/identifiers/generated/catalog.v1.json new file mode 100644 index 000000000..689789a72 --- /dev/null +++ b/products/identifiers/generated/catalog.v1.json @@ -0,0 +1,663 @@ +{ + "version": 1, + "baseUrl": "https://id.registrystack.org", + "entries": [ + { + "uri": "https://id.registrystack.org/ns/registry-manifest/v1#", + "kind": "namespace", + "status": "active", + "compatibilityLine": "v1", + "owner": "registry-manifest", + "title": "Registry Manifest v1 namespace", + "description": "JSON-LD namespace for Registry Manifest terms.", + "source": { + "path": "crates/registry-manifest-core/src/lib.rs", + "sha256": "ac1ef80a34aa0e477702fd5c5b61f715fd4f4cd8f4982c5e914ec8665ef6bf92" + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-relay/aggregate-data/denied", + "kind": "problem", + "status": "active", + "compatibilityLine": "relay-v2", + "owner": "relay-v2", + "title": "Aggregate data access is not permitted", + "description": "aggregate data access is not permitted", + "source": { + "path": "crates/registry-relay-v2/src/problem.rs", + "sha256": "033b915ebbabad355fd7987fa82e7821ea0e148925e6d05eb6d6f0aa9c7729a1" + }, + "problem": { + "code": "aggregate-data.denied", + "httpStatuses": [ + 403 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-relay/aggregate-data/invalid_request", + "kind": "problem", + "status": "active", + "compatibilityLine": "relay-v2", + "owner": "relay-v2", + "title": "Aggregate data request is invalid", + "description": "the aggregate data request is invalid", + "source": { + "path": "crates/registry-relay-v2/src/problem.rs", + "sha256": "033b915ebbabad355fd7987fa82e7821ea0e148925e6d05eb6d6f0aa9c7729a1" + }, + "problem": { + "code": "aggregate-data.invalid_request", + "httpStatuses": [ + 400 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-relay/aggregate-data/rate_limited", + "kind": "problem", + "status": "active", + "compatibilityLine": "relay-v2", + "owner": "relay-v2", + "title": "Aggregate data quota is exhausted", + "description": "the aggregate data quota is exhausted", + "source": { + "path": "crates/registry-relay-v2/src/problem.rs", + "sha256": "033b915ebbabad355fd7987fa82e7821ea0e148925e6d05eb6d6f0aa9c7729a1" + }, + "problem": { + "code": "aggregate-data.rate_limited", + "httpStatuses": [ + 429 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-relay/aggregate-data/too_large", + "kind": "problem", + "status": "active", + "compatibilityLine": "relay-v2", + "owner": "relay-v2", + "title": "Aggregate data request is too broad", + "description": "the aggregate data request exceeds its observation limit", + "source": { + "path": "crates/registry-relay-v2/src/problem.rs", + "sha256": "033b915ebbabad355fd7987fa82e7821ea0e148925e6d05eb6d6f0aa9c7729a1" + }, + "problem": { + "code": "aggregate-data.too_large", + "httpStatuses": [ + 413 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-relay/audit/unavailable", + "kind": "problem", + "status": "active", + "compatibilityLine": "relay-v2", + "owner": "relay-v2", + "title": "Required audit is unavailable", + "description": "required audit is unavailable", + "source": { + "path": "crates/registry-relay-v2/src/problem.rs", + "sha256": "033b915ebbabad355fd7987fa82e7821ea0e148925e6d05eb6d6f0aa9c7729a1" + }, + "problem": { + "code": "audit.unavailable", + "httpStatuses": [ + 503 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-relay/auth/invalid_credential", + "kind": "problem", + "status": "active", + "compatibilityLine": "relay-v2", + "owner": "relay-v2", + "title": "Bearer access token is invalid", + "description": "bearer access token validation failed", + "source": { + "path": "crates/registry-relay-v2/src/problem.rs", + "sha256": "033b915ebbabad355fd7987fa82e7821ea0e148925e6d05eb6d6f0aa9c7729a1" + }, + "problem": { + "code": "auth.invalid_credential", + "httpStatuses": [ + 401 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-relay/auth/missing_credential", + "kind": "problem", + "status": "active", + "compatibilityLine": "relay-v2", + "owner": "relay-v2", + "title": "Bearer access token is required", + "description": "a bearer access token is required", + "source": { + "path": "crates/registry-relay-v2/src/problem.rs", + "sha256": "033b915ebbabad355fd7987fa82e7821ea0e148925e6d05eb6d6f0aa9c7729a1" + }, + "problem": { + "code": "auth.missing_credential", + "httpStatuses": [ + 401 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-relay/consultation/denied", + "kind": "problem", + "status": "active", + "compatibilityLine": "relay-v2", + "owner": "relay-v2", + "title": "Consultation is not permitted", + "description": "the consultation is not permitted", + "source": { + "path": "crates/registry-relay-v2/src/problem.rs", + "sha256": "033b915ebbabad355fd7987fa82e7821ea0e148925e6d05eb6d6f0aa9c7729a1" + }, + "problem": { + "code": "consultation.denied", + "httpStatuses": [ + 403 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-relay/consultation/invalid_request", + "kind": "problem", + "status": "active", + "compatibilityLine": "relay-v2", + "owner": "relay-v2", + "title": "Consultation request is invalid", + "description": "the consultation request is invalid", + "source": { + "path": "crates/registry-relay-v2/src/problem.rs", + "sha256": "033b915ebbabad355fd7987fa82e7821ea0e148925e6d05eb6d6f0aa9c7729a1" + }, + "problem": { + "code": "consultation.invalid_request", + "httpStatuses": [ + 400 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-relay/consultation/rate_limited", + "kind": "problem", + "status": "active", + "compatibilityLine": "relay-v2", + "owner": "relay-v2", + "title": "Consultation quota is exhausted", + "description": "the consultation quota is exhausted", + "source": { + "path": "crates/registry-relay-v2/src/problem.rs", + "sha256": "033b915ebbabad355fd7987fa82e7821ea0e148925e6d05eb6d6f0aa9c7729a1" + }, + "problem": { + "code": "consultation.rate_limited", + "httpStatuses": [ + 429 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-relay/consultation/response_too_large", + "kind": "problem", + "status": "active", + "compatibilityLine": "relay-v2", + "owner": "relay-v2", + "title": "Consultation response is too large", + "description": "the consultation response exceeds the configured limit", + "source": { + "path": "crates/registry-relay-v2/src/problem.rs", + "sha256": "033b915ebbabad355fd7987fa82e7821ea0e148925e6d05eb6d6f0aa9c7729a1" + }, + "problem": { + "code": "consultation.response_too_large", + "httpStatuses": [ + 413 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-relay/consultation/unresolved", + "kind": "problem", + "status": "active", + "compatibilityLine": "relay-v2", + "owner": "relay-v2", + "title": "Requested record was not resolved", + "description": "the requested record was not resolved", + "source": { + "path": "crates/registry-relay-v2/src/problem.rs", + "sha256": "033b915ebbabad355fd7987fa82e7821ea0e148925e6d05eb6d6f0aa9c7729a1" + }, + "problem": { + "code": "consultation.unresolved", + "httpStatuses": [ + 404 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-relay/filter/invalid_value", + "kind": "problem", + "status": "active", + "compatibilityLine": "relay-v2", + "owner": "relay-v2", + "title": "Filter value is invalid", + "description": "filter value is invalid", + "source": { + "path": "crates/registry-relay-v2/src/problem.rs", + "sha256": "033b915ebbabad355fd7987fa82e7821ea0e148925e6d05eb6d6f0aa9c7729a1" + }, + "problem": { + "code": "filter.invalid_value", + "httpStatuses": [ + 400 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-relay/filter/unknown_field", + "kind": "problem", + "status": "active", + "compatibilityLine": "relay-v2", + "owner": "relay-v2", + "title": "Filter is not declared", + "description": "filter is not declared for this operation", + "source": { + "path": "crates/registry-relay-v2/src/problem.rs", + "sha256": "033b915ebbabad355fd7987fa82e7821ea0e148925e6d05eb6d6f0aa9c7729a1" + }, + "problem": { + "code": "filter.unknown_field", + "httpStatuses": [ + 400 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-relay/format/unsupported", + "kind": "problem", + "status": "active", + "compatibilityLine": "relay-v2", + "owner": "relay-v2", + "title": "Requested format is not supported", + "description": "the requested format is not supported", + "source": { + "path": "crates/registry-relay-v2/src/problem.rs", + "sha256": "033b915ebbabad355fd7987fa82e7821ea0e148925e6d05eb6d6f0aa9c7729a1" + }, + "problem": { + "code": "format.unsupported", + "httpStatuses": [ + 406 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-relay/internal/payload_too_large", + "kind": "problem", + "status": "active", + "compatibilityLine": "relay-v2", + "owner": "relay-v2", + "title": "Request body is too large", + "description": "request body exceeds the configured limit", + "source": { + "path": "crates/registry-relay-v2/src/problem.rs", + "sha256": "033b915ebbabad355fd7987fa82e7821ea0e148925e6d05eb6d6f0aa9c7729a1" + }, + "problem": { + "code": "internal.payload_too_large", + "httpStatuses": [ + 413 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-relay/internal/timeout", + "kind": "problem", + "status": "active", + "compatibilityLine": "relay-v2", + "owner": "relay-v2", + "title": "Request timed out", + "description": "request exceeded the configured timeout", + "source": { + "path": "crates/registry-relay-v2/src/problem.rs", + "sha256": "033b915ebbabad355fd7987fa82e7821ea0e148925e6d05eb6d6f0aa9c7729a1" + }, + "problem": { + "code": "internal.timeout", + "httpStatuses": [ + 504 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-relay/internal/unhandled", + "kind": "problem", + "status": "active", + "compatibilityLine": "relay-v2", + "owner": "relay-v2", + "title": "Request could not be served", + "description": "the request could not be served", + "source": { + "path": "crates/registry-relay-v2/src/problem.rs", + "sha256": "033b915ebbabad355fd7987fa82e7821ea0e148925e6d05eb6d6f0aa9c7729a1" + }, + "problem": { + "code": "internal.unhandled", + "httpStatuses": [ + 500 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-relay/internal/uri_too_long", + "kind": "problem", + "status": "active", + "compatibilityLine": "relay-v2", + "owner": "relay-v2", + "title": "Request URI is too long", + "description": "request URI exceeds the configured limit", + "source": { + "path": "crates/registry-relay-v2/src/problem.rs", + "sha256": "033b915ebbabad355fd7987fa82e7821ea0e148925e6d05eb6d6f0aa9c7729a1" + }, + "problem": { + "code": "internal.uri_too_long", + "httpStatuses": [ + 414 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-relay/query/cursor_invalid", + "kind": "problem", + "status": "active", + "compatibilityLine": "relay-v2", + "owner": "relay-v2", + "title": "Cursor is invalid", + "description": "cursor is invalid for this query", + "source": { + "path": "crates/registry-relay-v2/src/problem.rs", + "sha256": "033b915ebbabad355fd7987fa82e7821ea0e148925e6d05eb6d6f0aa9c7729a1" + }, + "problem": { + "code": "query.cursor_invalid", + "httpStatuses": [ + 400 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-relay/request/access_profile_invalid", + "kind": "problem", + "status": "active", + "compatibilityLine": "relay-v2", + "owner": "relay-v2", + "title": "Access profile selection is invalid", + "description": "access profile selection is invalid", + "source": { + "path": "crates/registry-relay-v2/src/problem.rs", + "sha256": "033b915ebbabad355fd7987fa82e7821ea0e148925e6d05eb6d6f0aa9c7729a1" + }, + "problem": { + "code": "request.access_profile_invalid", + "httpStatuses": [ + 400 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-relay/request/fields_invalid", + "kind": "problem", + "status": "active", + "compatibilityLine": "relay-v2", + "owner": "relay-v2", + "title": "Field selection is invalid", + "description": "field selection is invalid", + "source": { + "path": "crates/registry-relay-v2/src/problem.rs", + "sha256": "033b915ebbabad355fd7987fa82e7821ea0e148925e6d05eb6d6f0aa9c7729a1" + }, + "problem": { + "code": "request.fields_invalid", + "httpStatuses": [ + 400 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-relay/request/media_type_unsupported", + "kind": "problem", + "status": "active", + "compatibilityLine": "relay-v2", + "owner": "relay-v2", + "title": "Request media type is not supported", + "description": "request body must use application/json", + "source": { + "path": "crates/registry-relay-v2/src/problem.rs", + "sha256": "033b915ebbabad355fd7987fa82e7821ea0e148925e6d05eb6d6f0aa9c7729a1" + }, + "problem": { + "code": "request.media_type_unsupported", + "httpStatuses": [ + 415 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-relay/resource/not_found", + "kind": "problem", + "status": "active", + "compatibilityLine": "relay-v2", + "owner": "relay-v2", + "title": "Requested resource was not found", + "description": "the requested resource was not found", + "source": { + "path": "crates/registry-relay-v2/src/problem.rs", + "sha256": "033b915ebbabad355fd7987fa82e7821ea0e148925e6d05eb6d6f0aa9c7729a1" + }, + "problem": { + "code": "resource.not_found", + "httpStatuses": [ + 404 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-relay/service/not_ready", + "kind": "problem", + "status": "active", + "compatibilityLine": "relay-v2", + "owner": "relay-v2", + "title": "Service is not ready", + "description": "the service is not ready", + "source": { + "path": "crates/registry-relay-v2/src/problem.rs", + "sha256": "033b915ebbabad355fd7987fa82e7821ea0e148925e6d05eb6d6f0aa9c7729a1" + }, + "problem": { + "code": "service.not_ready", + "httpStatuses": [ + 503 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-relay/source/unavailable", + "kind": "problem", + "status": "active", + "compatibilityLine": "relay-v2", + "owner": "relay-v2", + "title": "Authoritative source is unavailable", + "description": "the authoritative source is unavailable", + "source": { + "path": "crates/registry-relay-v2/src/problem.rs", + "sha256": "033b915ebbabad355fd7987fa82e7821ea0e148925e6d05eb6d6f0aa9c7729a1" + }, + "problem": { + "code": "source.unavailable", + "httpStatuses": [ + 503 + ] + } + }, + { + "uri": "https://id.registrystack.org/schemas/identifiers/catalog.v1.schema.json", + "kind": "schema", + "status": "active", + "compatibilityLine": "v1", + "owner": "identifiers", + "title": "Registry Stack identifier catalog v1", + "description": "JSON Schema for the Registry Stack identifier catalog.", + "source": { + "path": "products/identifiers/contracts/catalog.v1.schema.json", + "sha256": "25eb7b86185ab1d3aa5919752790c2600b5610b484397836eab17b82924e9d01" + }, + "artifact": { + "path": "products/identifiers/contracts/catalog.v1.schema.json", + "sha256": "25eb7b86185ab1d3aa5919752790c2600b5610b484397836eab17b82924e9d01", + "mediaType": "application/schema+json" + } + }, + { + "uri": "https://id.registrystack.org/schemas/registry-relay/audit-event/v2alpha1", + "kind": "schema", + "status": "active", + "compatibilityLine": "v2alpha1", + "owner": "relay-v2", + "title": "Registry Relay value-free audit event", + "description": "Relay V2 generated public JSON Schema.", + "source": { + "path": "crates/registry-relay-v2/src/artifacts.rs", + "sha256": "7ed7c76aa09ff3ffc2ee9f7fea17f8378f1246fc5855a10cda50a7920e711a1a" + }, + "artifact": { + "path": "products/identifiers/generated/artifacts/registry-relay/audit-event/v2alpha1.json", + "sha256": "e2b2be3baabb1c5f147a4095eade0d85702d814ec93049b62208fdd7ab1265b8", + "mediaType": "application/schema+json" + } + }, + { + "uri": "https://id.registrystack.org/schemas/registry-relay/authoring/registry.v2alpha1.schema.json", + "kind": "schema", + "status": "active", + "compatibilityLine": "v2alpha1", + "owner": "relay-v2", + "title": "Relay V2 governed Registry contract", + "description": "Relay V2 authoring JSON Schema.", + "source": { + "path": "crates/registry-relayctl/schemas/authoring/registry.schema.json", + "sha256": "caed080f4eef3c39d8728cb56722d8d7f5cad6342d46750a0046eb2c5defc186" + }, + "artifact": { + "path": "crates/registry-relayctl/schemas/authoring/registry.schema.json", + "sha256": "caed080f4eef3c39d8728cb56722d8d7f5cad6342d46750a0046eb2c5defc186", + "mediaType": "application/schema+json" + } + }, + { + "uri": "https://id.registrystack.org/schemas/registry-relay/authoring/runtime.v2alpha1.schema.json", + "kind": "schema", + "status": "active", + "compatibilityLine": "v2alpha1", + "owner": "relay-v2", + "title": "Relay V2 deployment binding", + "description": "Relay V2 authoring JSON Schema.", + "source": { + "path": "crates/registry-relayctl/schemas/authoring/runtime.schema.json", + "sha256": "add5e2aeb746d5d58706180ef8d9173817dbb907de0305830ee4b3f03322120e" + }, + "artifact": { + "path": "crates/registry-relayctl/schemas/authoring/runtime.schema.json", + "sha256": "add5e2aeb746d5d58706180ef8d9173817dbb907de0305830ee4b3f03322120e", + "mediaType": "application/schema+json" + } + }, + { + "uri": "https://id.registrystack.org/vocab/codelist", + "kind": "vocabulary-term", + "status": "active", + "compatibilityLine": "relay-v2", + "owner": "relay-v2", + "title": "Codelist", + "description": "Relay V2 Registry Core predicate binding a controlled property to its codelist.", + "source": { + "path": "crates/registry-relay-v2/src/semantics.rs", + "sha256": "5614126aba7d38bff956209b6e68823b142e9f2ddf3c294d2f279cec72938218" + } + }, + { + "uri": "https://id.registrystack.org/vocab/coordinateReferenceSystem", + "kind": "vocabulary-term", + "status": "active", + "compatibilityLine": "relay-v2", + "owner": "relay-v2", + "title": "Coordinate reference system", + "description": "Relay V2 Registry Core predicate identifying a spatial property's coordinate reference system.", + "source": { + "path": "crates/registry-relay-v2/src/semantics.rs", + "sha256": "5614126aba7d38bff956209b6e68823b142e9f2ddf3c294d2f279cec72938218" + } + }, + { + "uri": "https://id.registrystack.org/vocab/core/", + "kind": "vocabulary", + "status": "active", + "compatibilityLine": "relay-v2", + "owner": "relay-v2", + "title": "Registry Relay core vocabulary", + "description": "Vocabulary base for Relay V2 Registry Core semantic predicates.", + "source": { + "path": "crates/registry-relay-v2/src/semantics.rs", + "sha256": "5614126aba7d38bff956209b6e68823b142e9f2ddf3c294d2f279cec72938218" + } + }, + { + "uri": "https://id.registrystack.org/vocab/geometryType", + "kind": "vocabulary-term", + "status": "active", + "compatibilityLine": "relay-v2", + "owner": "relay-v2", + "title": "Geometry type", + "description": "Relay V2 Registry Core predicate identifying a spatial property's geometry type.", + "source": { + "path": "crates/registry-relay-v2/src/semantics.rs", + "sha256": "5614126aba7d38bff956209b6e68823b142e9f2ddf3c294d2f279cec72938218" + } + }, + { + "uri": "https://id.registrystack.org/vocab/handling", + "kind": "vocabulary", + "status": "active", + "compatibilityLine": "relay-v2", + "owner": "relay-v2", + "title": "Registry Relay handling vocabulary", + "description": "Ordered Relay V2 technical handling levels: public, internal, confidential, and restricted.", + "source": { + "path": "products/relay-v2/CONCEPT.md", + "sha256": "4695621a27198e37c02f235484c8ac61dadc3497da3e0d14350f1fc5c93c558d" + } + }, + { + "uri": "https://id.registrystack.org/vocab/sourceRequired", + "kind": "vocabulary-term", + "status": "active", + "compatibilityLine": "relay-v2", + "owner": "relay-v2", + "title": "Source required", + "description": "Relay V2 Registry Core predicate indicating whether a source value is required.", + "source": { + "path": "crates/registry-relay-v2/src/semantics.rs", + "sha256": "5614126aba7d38bff956209b6e68823b142e9f2ddf3c294d2f279cec72938218" + } + } + ] +} diff --git a/products/identifiers/scripts/check.sh b/products/identifiers/scripts/check.sh new file mode 100755 index 000000000..31c0e9506 --- /dev/null +++ b/products/identifiers/scripts/check.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +temporary="$(mktemp -d)" +trap 'rm -rf "${temporary}"' EXIT + +cd "${repo_root}" +python3 -m unittest discover \ + --start-directory products/identifiers/scripts \ + --pattern 'test_*.py' +products/identifiers/scripts/generate.py \ + --output "${temporary}/catalog.v1.json" +cmp products/identifiers/generated/catalog.v1.json \ + "${temporary}/catalog.v1.json" +echo "Registry Stack identifier catalog is complete and reproducible." diff --git a/products/identifiers/scripts/generate.py b/products/identifiers/scripts/generate.py new file mode 100755 index 000000000..be0e4768e --- /dev/null +++ b/products/identifiers/scripts/generate.py @@ -0,0 +1,557 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import fnmatch +import hashlib +import json +import os +import re +import subprocess +import tempfile +from pathlib import Path +from typing import Any + + +BASE_URL = "https://id.registrystack.org" +REPO_ROOT = Path(__file__).resolve().parents[3] +PRODUCT_ROOT = REPO_ROOT / "products" / "identifiers" +SOURCE_CONFIG = PRODUCT_ROOT / "contracts" / "catalog-source.json" +GENERATED_CATALOG = PRODUCT_ROOT / "generated" / "catalog.v1.json" +GENERATED_AUDIT_SCHEMA = ( + PRODUCT_ROOT + / "generated" + / "artifacts" + / "registry-relay" + / "audit-event" + / "v2alpha1.json" +) +PROBLEM_SOURCE = Path("crates/registry-relay-v2/src/problem.rs") +REFERENCE_URI_RE = re.compile( + r"https://id\.registrystack\.org/[^\s<>{}\"'`\\]+" +) +REFERENCE_TEMPLATES = { + f"{BASE_URL}/", + f"{BASE_URL}/problems/...", + f"{BASE_URL}/problems/..", + f"{BASE_URL}/problems/", + f"{BASE_URL}/problems/registry-relay/", + f"{BASE_URL}/schemas/", +} + + +class CatalogError(ValueError): + pass + + +def read_json(path: Path) -> Any: + try: + return json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise CatalogError(f"could not read JSON {path}: {error}") from error + + +def sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def relative_path(repo_root: Path, path: Path) -> str: + try: + return path.relative_to(repo_root).as_posix() + except ValueError as error: + raise CatalogError(f"source path is outside the repository: {path}") from error + + +def source_record(repo_root: Path, path: Path) -> dict[str, str]: + if not path.is_file(): + raise CatalogError(f"catalog source does not exist: {path}") + return {"path": relative_path(repo_root, path), "sha256": sha256(path)} + + +def repository_files(repo_root: Path) -> tuple[Path, ...]: + result = subprocess.run( + ["git", "-C", str(repo_root), "ls-files", "-z"], + check=False, + capture_output=True, + ) + if result.returncode != 0: + message = result.stderr.decode("utf-8", errors="replace").strip() + raise CatalogError(f"could not enumerate tracked repository files: {message}") + try: + relative_paths = [ + value.decode("utf-8") + for value in result.stdout.split(b"\0") + if value + ] + except UnicodeDecodeError as error: + raise CatalogError("tracked repository path is not valid UTF-8") from error + return tuple( + path + for relative in relative_paths + if (path := repo_root / relative).is_file() + ) + + +def load_source_config(config_path: Path) -> dict[str, Any]: + config = read_json(config_path) + if not isinstance(config, dict) or set(config) != { + "version", + "baseUrl", + "referenceExclusions", + "schemaSources", + "records", + }: + raise CatalogError("catalog source has an invalid top-level shape") + if config["version"] != 1 or config["baseUrl"] != BASE_URL: + raise CatalogError("catalog source version or base URL is invalid") + if ( + not isinstance(config["referenceExclusions"], list) + or not isinstance(config["schemaSources"], list) + or not isinstance(config["records"], list) + ): + raise CatalogError("catalog source lists are invalid") + for index, exclusion in enumerate(config["referenceExclusions"]): + if not isinstance(exclusion, dict) or set(exclusion) != { + "glob", + "classification", + "reason", + }: + raise CatalogError( + f"referenceExclusions[{index}] has an invalid shape" + ) + if not all( + isinstance(exclusion[key], str) and exclusion[key].strip() + for key in ("glob", "classification", "reason") + ): + raise CatalogError( + f"referenceExclusions[{index}] has a blank value" + ) + return config + + +def path_is_excluded( + repo_root: Path, path: Path, exclusions: list[dict[str, str]] +) -> bool: + relative = relative_path(repo_root, path) + return any(fnmatch.fnmatch(relative, exclusion["glob"]) for exclusion in exclusions) + + +def validate_reference_exclusions( + repo_root: Path, + repository_paths: tuple[Path, ...], + exclusions: list[dict[str, str]], +) -> None: + relative_paths = [relative_path(repo_root, path) for path in repository_paths] + for index, exclusion in enumerate(exclusions): + if not any( + fnmatch.fnmatch(relative, exclusion["glob"]) + for relative in relative_paths + ): + raise CatalogError( + f"referenceExclusions[{index}] matched no tracked files: " + f"{exclusion['glob']}" + ) + + +def public_schema_files( + repo_root: Path, + repository_paths: tuple[Path, ...], + exclusions: list[dict[str, str]], +) -> dict[str, Path]: + found: dict[str, Path] = {} + for path in repository_paths: + if path.suffix != ".json": + continue + if path_is_excluded(repo_root, path, exclusions): + continue + try: + document = read_json(path) + except CatalogError: + continue + if not isinstance(document, dict): + continue + uri = document.get("$id") + if not isinstance(uri, str) or not uri.startswith(f"{BASE_URL}/"): + continue + if uri in found: + raise CatalogError( + "public schema identifier is duplicated by " + f"{relative_path(repo_root, found[uri])} and " + f"{relative_path(repo_root, path)}: {uri}" + ) + found[uri] = path + return found + + +def schema_entries( + repo_root: Path, + groups: list[dict[str, Any]], + repository_paths: tuple[Path, ...], + exclusions: list[dict[str, str]], +) -> list[dict[str, Any]]: + entries: list[dict[str, Any]] = [] + covered_paths: set[Path] = set() + for index, group in enumerate(groups): + required = { + "glob", + "owner", + "status", + "compatibilityLine", + "description", + } + keys = set(group) if isinstance(group, dict) else set() + if ( + not isinstance(group, dict) + or keys not in (required, required | {"sourcePath"}) + ): + raise CatalogError(f"schemaSources[{index}] has an invalid shape") + if group["status"] != "active": + raise CatalogError(f"schemaSources[{index}] has an invalid status") + if not isinstance(group["compatibilityLine"], str) or not group[ + "compatibilityLine" + ].strip(): + raise CatalogError( + f"schemaSources[{index}] has an invalid compatibility line" + ) + matched = sorted( + path + for path in repository_paths + if fnmatch.fnmatch(relative_path(repo_root, path), group["glob"]) + ) + if not matched: + raise CatalogError(f"schema source glob matched no files: {group['glob']}") + for path in matched: + if path in covered_paths: + raise CatalogError( + f"schema source belongs to multiple groups: {relative_path(repo_root, path)}" + ) + document = read_json(path) + uri = document.get("$id") if isinstance(document, dict) else None + if not isinstance(uri, str) or not uri.startswith(f"{BASE_URL}/"): + continue + covered_paths.add(path) + digest = sha256(path) + source_path = ( + repo_root / group["sourcePath"] + if "sourcePath" in group + else path + ) + entries.append( + { + "uri": uri, + "kind": "schema", + "status": group["status"], + "compatibilityLine": group["compatibilityLine"], + "owner": group["owner"], + "title": document.get("title") + or f"Registry Stack JSON Schema: {path.name}", + "description": group["description"], + "source": source_record(repo_root, source_path), + "artifact": { + "path": relative_path(repo_root, path), + "sha256": digest, + "mediaType": "application/schema+json", + }, + } + ) + + all_public = public_schema_files(repo_root, repository_paths, exclusions) + covered_uris = {entry["uri"] for entry in entries} + missing = sorted(set(all_public) - covered_uris) + if missing: + paths = [relative_path(repo_root, all_public[uri]) for uri in missing] + raise CatalogError(f"public schemas are outside the closed source groups: {paths}") + return entries + + +def explicit_entries( + repo_root: Path, records: list[dict[str, Any]] +) -> list[dict[str, Any]]: + entries: list[dict[str, Any]] = [] + expected = { + "uri", + "kind", + "status", + "compatibilityLine", + "owner", + "title", + "description", + "sourcePath", + } + for index, record in enumerate(records): + if not isinstance(record, dict) or set(record) != expected: + raise CatalogError(f"records[{index}] has an invalid shape") + source_path = repo_root / record["sourcePath"] + entries.append( + { + "uri": record["uri"], + "kind": record["kind"], + "status": record["status"], + "compatibilityLine": record["compatibilityLine"], + "owner": record["owner"], + "title": record["title"], + "description": record["description"], + "source": source_record(repo_root, source_path), + } + ) + return entries + + +def problem_entries(repo_root: Path, problem_catalog_path: Path) -> list[dict[str, Any]]: + catalog = read_json(problem_catalog_path) + raw_entries = catalog.get("entries") if isinstance(catalog, dict) else None + if not isinstance(raw_entries, list) or not raw_entries: + raise CatalogError("Relay V2 problem catalog has no entries") + source = source_record(repo_root, repo_root / PROBLEM_SOURCE) + entries: list[dict[str, Any]] = [] + expected = {"uri", "code", "title", "description", "httpStatuses"} + for index, entry in enumerate(raw_entries): + if not isinstance(entry, dict) or set(entry) != expected: + raise CatalogError(f"Relay V2 problem entry {index} has an invalid shape") + if entry["uri"] != ( + f"{BASE_URL}/problems/registry-relay/" + + entry["code"].replace(".", "/") + ): + raise CatalogError(f"Relay V2 problem URI and code disagree: {entry}") + entries.append( + { + "uri": entry["uri"], + "kind": "problem", + "status": "active", + "compatibilityLine": "relay-v2", + "owner": "relay-v2", + "title": entry["title"], + "description": entry["description"], + "source": source, + "problem": { + "code": entry["code"], + "httpStatuses": entry["httpStatuses"], + }, + } + ) + return entries + + +def validate_entries(repo_root: Path, entries: list[dict[str, Any]]) -> None: + seen: dict[str, str] = {} + for entry in entries: + uri = entry["uri"] + if not isinstance(uri, str) or not uri.startswith(f"{BASE_URL}/"): + raise CatalogError(f"identifier is outside the Registry Stack domain: {uri}") + if uri in seen: + raise CatalogError( + f"identifier is duplicated by {seen[uri]} and {entry['source']['path']}: {uri}" + ) + seen[uri] = entry["source"]["path"] + if entry["kind"] not in { + "problem", + "schema", + "context", + "namespace", + "vocabulary", + "vocabulary-term", + }: + raise CatalogError(f"identifier has an invalid kind: {entry}") + if entry["status"] != "active": + raise CatalogError(f"identifier has an invalid status: {entry}") + if not isinstance(entry.get("compatibilityLine"), str) or not entry[ + "compatibilityLine" + ].strip(): + raise CatalogError( + f"identifier has an invalid compatibility line: {entry}" + ) + source_path = repo_root / entry["source"]["path"] + if sha256(source_path) != entry["source"]["sha256"]: + raise CatalogError(f"source digest does not match: {entry['source']['path']}") + artifact = entry.get("artifact") + if artifact is not None: + artifact_path = repo_root / artifact["path"] + if sha256(artifact_path) != artifact["sha256"]: + raise CatalogError(f"artifact digest does not match: {artifact['path']}") + if entry["kind"] == "schema": + document = read_json(artifact_path) + if document.get("$id") != uri: + raise CatalogError(f"schema $id and catalog URI disagree: {uri}") + + +def reference_uris(path: Path) -> set[str]: + try: + text = path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + return set() + return { + match.group(0).rstrip(".,;:)]") + for match in REFERENCE_URI_RE.finditer(text) + } + + +def validate_reference_closure( + repo_root: Path, + entries: list[dict[str, Any]], + exclusions: list[dict[str, str]], + repository_paths: tuple[Path, ...], +) -> None: + validate_reference_exclusions(repo_root, repository_paths, exclusions) + active_uris = {entry["uri"] for entry in entries} + adopter_prefixes = { + entry["uri"] + for entry in entries + if entry["kind"] == "vocabulary" and entry["uri"].endswith("/") + } + unclassified: list[str] = [] + for path in repository_paths: + if path == GENERATED_CATALOG: + continue + relative = relative_path(repo_root, path) + excluded = path_is_excluded(repo_root, path, exclusions) + for uri in reference_uris(path): + if ( + uri in active_uris + or uri in REFERENCE_TEMPLATES + or any(uri.startswith(prefix) for prefix in adopter_prefixes) + ): + continue + if not excluded: + unclassified.append(f"{relative}: {uri}") + if unclassified: + raise CatalogError( + "identifier references are outside the active catalog and exclusion " + f"inventory: {sorted(unclassified)}" + ) + + +def build_catalog( + repo_root: Path, config_path: Path, problem_catalog_path: Path +) -> dict[str, Any]: + config = load_source_config(config_path) + repository_paths = repository_files(repo_root) + entries = [ + *problem_entries(repo_root, problem_catalog_path), + *schema_entries( + repo_root, + config["schemaSources"], + repository_paths, + config["referenceExclusions"], + ), + *explicit_entries(repo_root, config["records"]), + ] + entries.sort(key=lambda entry: entry["uri"]) + validate_entries(repo_root, entries) + validate_reference_closure( + repo_root, + entries, + config["referenceExclusions"], + repository_paths, + ) + return {"version": 1, "baseUrl": BASE_URL, "entries": entries} + + +def render(catalog: dict[str, Any]) -> bytes: + return (json.dumps(catalog, indent=2, ensure_ascii=False) + "\n").encode("utf-8") + + +def generate_problem_catalog(repo_root: Path, output: Path) -> None: + environment = os.environ.copy() + environment.setdefault("CARGO_INCREMENTAL", "0") + environment.setdefault("CARGO_PROFILE_DEV_DEBUG", "0") + environment.setdefault("CARGO_PROFILE_TEST_DEBUG", "0") + subprocess.run( + [ + "cargo", + "run", + "--locked", + "--quiet", + "-p", + "registry-relay-v2", + "--example", + "problem-catalog", + "--", + "--output", + str(output), + ], + cwd=repo_root, + env=environment, + check=True, + ) + + +def generate_audit_schema(repo_root: Path, output: Path) -> None: + environment = os.environ.copy() + environment.setdefault("CARGO_INCREMENTAL", "0") + environment.setdefault("CARGO_PROFILE_DEV_DEBUG", "0") + environment.setdefault("CARGO_PROFILE_TEST_DEBUG", "0") + subprocess.run( + [ + "cargo", + "run", + "--locked", + "--quiet", + "-p", + "registry-relay-v2", + "--example", + "audit-event-schema", + "--", + "--output", + str(output), + ], + cwd=repo_root, + env=environment, + check=True, + ) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + destination = parser.add_mutually_exclusive_group() + destination.add_argument("--write", action="store_true") + destination.add_argument("--output", type=Path) + destination.add_argument("--check-references", action="store_true") + parser.add_argument("--problem-catalog", type=Path) + return parser.parse_args() + + +def main() -> None: + args = parse_args() + if args.check_references: + config = load_source_config(SOURCE_CONFIG) + catalog = read_json(GENERATED_CATALOG) + entries = catalog.get("entries") if isinstance(catalog, dict) else None + if not isinstance(entries, list): + raise CatalogError("generated identifier catalog has no entries") + validate_reference_closure( + REPO_ROOT, + entries, + config["referenceExclusions"], + repository_files(REPO_ROOT), + ) + print("Registry Stack identifier reference closure is complete.") + return + with tempfile.TemporaryDirectory(prefix="registry-identifiers-") as temp: + generated_audit_schema = Path(temp) / "audit-event.v2alpha1.json" + generate_audit_schema(REPO_ROOT, generated_audit_schema) + if args.write: + GENERATED_AUDIT_SCHEMA.parent.mkdir(parents=True, exist_ok=True) + GENERATED_AUDIT_SCHEMA.write_bytes(generated_audit_schema.read_bytes()) + elif ( + not GENERATED_AUDIT_SCHEMA.is_file() + or GENERATED_AUDIT_SCHEMA.read_bytes() != generated_audit_schema.read_bytes() + ): + raise CatalogError( + "generated Relay V2 audit event schema is stale; run with --write" + ) + + problem_catalog = args.problem_catalog or Path(temp) / "problems.json" + if args.problem_catalog is None: + generate_problem_catalog(REPO_ROOT, problem_catalog) + catalog = build_catalog(REPO_ROOT, SOURCE_CONFIG, problem_catalog) + output = GENERATED_CATALOG if args.write else args.output + if output is None: + print(render(catalog).decode("utf-8"), end="") + return + output.parent.mkdir(parents=True, exist_ok=True) + output.write_bytes(render(catalog)) + + +if __name__ == "__main__": + main() diff --git a/products/identifiers/scripts/test_generate.py b/products/identifiers/scripts/test_generate.py new file mode 100644 index 000000000..038bdfadc --- /dev/null +++ b/products/identifiers/scripts/test_generate.py @@ -0,0 +1,214 @@ +from __future__ import annotations + +import importlib.util +import json +import subprocess +import tempfile +import unittest +from pathlib import Path + + +SCRIPT = Path(__file__).with_name("generate.py") +SPEC = importlib.util.spec_from_file_location("identifier_catalog_generate", SCRIPT) +assert SPEC is not None and SPEC.loader is not None +generate = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(generate) + + +class CatalogGeneratorTest(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory() + self.root = Path(self.temporary.name) + (self.root / "schemas").mkdir() + (self.root / "src").mkdir() + (self.root / "src" / "problem.rs").write_text("problem source\n") + (self.root / "src" / "vocab.rs").write_text("vocabulary source\n") + self.schema_uri = f"{generate.BASE_URL}/schemas/example/v1.json" + (self.root / "schemas" / "v1.json").write_text( + json.dumps({"$id": self.schema_uri, "title": "Example schema"}) + ) + self.problem_catalog = self.root / "problems.json" + self.problem_catalog.write_text( + json.dumps( + { + "entries": [ + { + "uri": f"{generate.BASE_URL}/problems/registry-relay/example/failed", + "code": "example.failed", + "title": "Example failed", + "description": "the example failed", + "httpStatuses": [400], + } + ] + } + ) + ) + self.config = self.root / "source.json" + self.write_config() + subprocess.run(["git", "init", "-q", str(self.root)], check=True) + self.track(".") + + def tearDown(self) -> None: + self.temporary.cleanup() + + def write_config(self, records=None, exclusions=None) -> None: + self.config.write_text( + json.dumps( + { + "version": 1, + "baseUrl": generate.BASE_URL, + "referenceExclusions": exclusions or [], + "schemaSources": [ + { + "glob": "schemas/*.json", + "owner": "example", + "status": "active", + "compatibilityLine": "v1", + "description": "Example schema.", + } + ], + "records": records + if records is not None + else [ + { + "uri": f"{generate.BASE_URL}/vocab/example", + "kind": "vocabulary", + "status": "active", + "compatibilityLine": "v1", + "owner": "example", + "title": "Example vocabulary", + "description": "Example terms.", + "sourcePath": "src/vocab.rs", + } + ], + } + ) + ) + + def track(self, *paths: str) -> None: + subprocess.run( + ["git", "-C", str(self.root), "add", "--", *paths], + check=True, + ) + + def build(self): + original = generate.PROBLEM_SOURCE + generate.PROBLEM_SOURCE = Path("src/problem.rs") + try: + return generate.build_catalog(self.root, self.config, self.problem_catalog) + finally: + generate.PROBLEM_SOURCE = original + + def test_catalog_binds_problem_schema_and_vocabulary_sources(self) -> None: + catalog = self.build() + self.assertEqual(catalog["version"], 1) + self.assertEqual([entry["uri"] for entry in catalog["entries"]], sorted( + entry["uri"] for entry in catalog["entries"] + )) + schema = next(entry for entry in catalog["entries"] if entry["kind"] == "schema") + self.assertEqual(schema["uri"], self.schema_uri) + self.assertEqual(schema["compatibilityLine"], "v1") + self.assertEqual(schema["source"]["sha256"], schema["artifact"]["sha256"]) + + def test_schema_outside_closed_groups_is_rejected(self) -> None: + outside = self.root / "outside.json" + outside.write_text( + json.dumps({"$id": f"{generate.BASE_URL}/schemas/outside.json"}) + ) + self.track("outside.json") + with self.assertRaisesRegex(generate.CatalogError, "outside the closed source groups"): + self.build() + + def test_generated_schema_binds_distinct_source_and_artifact(self) -> None: + document = json.loads(self.config.read_text()) + document["schemaSources"][0]["sourcePath"] = "src/vocab.rs" + self.config.write_text(json.dumps(document)) + schema = next( + entry for entry in self.build()["entries"] if entry["kind"] == "schema" + ) + self.assertEqual(schema["source"]["path"], "src/vocab.rs") + self.assertEqual(schema["artifact"]["path"], "schemas/v1.json") + self.assertNotEqual(schema["source"]["sha256"], schema["artifact"]["sha256"]) + + def test_duplicate_identifier_is_rejected(self) -> None: + self.write_config( + [ + { + "uri": self.schema_uri, + "kind": "vocabulary", + "status": "active", + "compatibilityLine": "v1", + "owner": "example", + "title": "Duplicate", + "description": "Duplicate identifier.", + "sourcePath": "src/vocab.rs", + } + ] + ) + with self.assertRaisesRegex(generate.CatalogError, "duplicated"): + self.build() + + def test_problem_code_must_match_its_uri(self) -> None: + document = json.loads(self.problem_catalog.read_text()) + document["entries"][0]["uri"] = f"{generate.BASE_URL}/problems/wrong" + self.problem_catalog.write_text(json.dumps(document)) + with self.assertRaisesRegex(generate.CatalogError, "URI and code disagree"): + self.build() + + def test_retired_source_group_is_rejected(self) -> None: + document = json.loads(self.config.read_text()) + document["schemaSources"][0]["status"] = "retired" + self.config.write_text(json.dumps(document)) + with self.assertRaisesRegex(generate.CatalogError, "invalid status"): + self.build() + + def test_unclassified_identifier_reference_is_rejected(self) -> None: + (self.root / "src" / "unclassified.rs").write_text( + f'const URI: &str = "{generate.BASE_URL}/unclassified/value";\n' + ) + self.track("src/unclassified.rs") + with self.assertRaisesRegex(generate.CatalogError, "outside the active catalog"): + self.build() + + def test_reviewed_identifier_reference_exclusion_is_accepted(self) -> None: + (self.root / "src" / "legacy.rs").write_text( + f'const URI: &str = "{generate.BASE_URL}/legacy/value";\n' + ) + self.track("src/legacy.rs") + self.write_config( + exclusions=[ + { + "glob": "src/legacy.rs", + "classification": "legacy-source", + "reason": "Legacy fixture retained for compatibility tests.", + } + ] + ) + catalog = self.build() + self.assertNotIn( + f"{generate.BASE_URL}/legacy/value", + {entry["uri"] for entry in catalog["entries"]}, + ) + + def test_untracked_identifier_reference_is_ignored(self) -> None: + (self.root / "src" / "scratch.rs").write_text( + f'const URI: &str = "{generate.BASE_URL}/scratch/value";\n' + ) + self.build() + + def test_reference_exclusion_must_match_a_tracked_file(self) -> None: + self.write_config( + exclusions=[ + { + "glob": "src/missing.rs", + "classification": "legacy-source", + "reason": "A stale exclusion must not survive silently.", + } + ] + ) + with self.assertRaisesRegex(generate.CatalogError, "matched no tracked files"): + self.build() + + +if __name__ == "__main__": + unittest.main() diff --git a/products/manifest/CHANGELOG.md b/products/manifest/CHANGELOG.md index b8c7bcd53..ba742a417 100644 --- a/products/manifest/CHANGELOG.md +++ b/products/manifest/CHANGELOG.md @@ -7,6 +7,12 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +- BREAKING: `registry-manifest/v1` no longer reserves the `registry_relay` + vocabulary prefix or expands it to the retired Registry Relay V1 namespace. + Replace those compact identifiers with absolute IRIs, or declare + `vocabularies.registry_relay` with an institution-owned active HTTP(S) + namespace, then validate and republish the rendered metadata. + ## [0.19.0] - 2026-08-11 - No user-visible Registry Manifest format changes. diff --git a/release/OPERATIONS.md b/release/OPERATIONS.md index 3b8b8a46a..51b2f482f 100644 --- a/release/OPERATIONS.md +++ b/release/OPERATIONS.md @@ -79,6 +79,11 @@ or release-tool implementation changes into this PR. Merge after the protected checks pass. The merge commit is both the candidate source and future tag target. There is no finalization or closeout PR. +Starting with version `0.19.1`, the release manifest records the committed +identifier catalog path, SHA-256 digest, and active entry count. The planner +checks that binding against the source tree. Live resolver availability remains +an asynchronous publication smoke rather than a candidate-build gate. + ## Request and verify one candidate Resolve current protected `main` and request the candidate: diff --git a/release/scripts/registry-release b/release/scripts/registry-release index 4f9ffd972..61ae9d9b2 100755 --- a/release/scripts/registry-release +++ b/release/scripts/registry-release @@ -45,6 +45,10 @@ EVIDENCE_OID4VCI_MINIMUM_VERSION = (0, 18, 0) REGISTRYCTL_INSTALLER_MINIMUM_VERSION = (0, 14, 0) RELAY_V2_RELEASE_MINIMUM_VERSION = (0, 19, 0) RELAY_INSTALLER_MINIMUM_VERSION = (0, 19, 1) +IDENTIFIER_CATALOG_RELEASE_MINIMUM_VERSION = (0, 19, 1) +IDENTIFIER_CATALOG_RELATIVE_PATH = "products/identifiers/generated/catalog.v1.json" +# Historical v0.10-v0.16 releases shipped the V1 Relay and Notary identities. +# Current v0.19+ releases are validated against RELAY_V2_ARTIFACT_INVENTORY. EXACT_ARTIFACT_INVENTORY = { "registry-notary", "registry-notary-cel-worker", @@ -219,6 +223,67 @@ def artifact_inventory_errors(version: str, artifacts: dict[Any, Any]) -> list[s return errors +def identifier_catalog_errors( + version: str, + binding: Any, + repo_root: Path = ROOT, +) -> list[str]: + parsed_version = release_version_tuple(version) + required = ( + parsed_version is not None + and parsed_version >= IDENTIFIER_CATALOG_RELEASE_MINIMUM_VERSION + ) + if binding is None and not required: + return [] + if not isinstance(binding, dict): + return [ + "identifier_catalog is required for versions 0.19.1 and later" + if required + else "identifier_catalog must be an object" + ] + if set(binding) != {"path", "sha256", "entry_count"}: + return [ + "identifier_catalog must contain exactly path, sha256, and entry_count" + ] + errors: list[str] = [] + if binding.get("path") != IDENTIFIER_CATALOG_RELATIVE_PATH: + errors.append( + f"identifier_catalog.path must be {IDENTIFIER_CATALOG_RELATIVE_PATH}" + ) + digest_value = binding.get("sha256") + if not isinstance(digest_value, str) or SHA256_HEX.fullmatch(digest_value) is None: + errors.append("identifier_catalog.sha256 must be a lowercase SHA-256 digest") + entry_count = binding.get("entry_count") + if not isinstance(entry_count, int) or isinstance(entry_count, bool) or entry_count < 1: + errors.append("identifier_catalog.entry_count must be a positive integer") + catalog_path = repo_root / IDENTIFIER_CATALOG_RELATIVE_PATH + if not errors and not catalog_path.is_file(): + errors.append("the committed identifier catalog is missing") + if not errors: + if sha256(catalog_path) != digest_value: + errors.append( + "identifier_catalog.sha256 does not match the committed catalog bytes" + ) + try: + catalog = json.loads(catalog_path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError): + errors.append("committed identifier catalog is not readable JSON") + else: + entries = catalog.get("entries") if isinstance(catalog, dict) else None + if not isinstance(entries, list) or len(entries) != entry_count: + errors.append( + "identifier_catalog.entry_count does not match the committed catalog" + ) + elif any( + not isinstance(entry, dict) or entry.get("status") != "active" + for entry in entries + ): + errors.append( + "the release identifier catalog must contain only active entries" + ) + return errors + + def require_registryctl_image_lock_release_version(version: str) -> None: parsed = release_version_tuple(version) if parsed is None: @@ -580,6 +645,9 @@ def validate(manifest_path: Path) -> int: stack = manifest.get("stack", {}) if isinstance(manifest, dict) else {} artifacts = manifest.get("artifacts", {}) if isinstance(manifest, dict) else {} external = manifest.get("external", {}) if isinstance(manifest, dict) else {} + identifier_catalog = ( + manifest.get("identifier_catalog") if isinstance(manifest, dict) else None + ) version = str(stack.get("version", "")) source_tag = str(stack.get("source_tag", "")) has_source_ref = "source_ref" in stack @@ -608,6 +676,7 @@ def validate(manifest_path: Path) -> int: "stack.source_ref may be HEAD only when stack.status is draft" ) errors.extend(artifact_inventory_errors(version, artifacts)) + errors.extend(identifier_catalog_errors(version, identifier_catalog)) for name, artifact_version in sorted(artifacts.items()): if str(artifact_version) != version: errors.append(f"artifact {name} version {artifact_version} does not match stack version {version}") @@ -1850,6 +1919,15 @@ def validate_selected_manifest(repo: Path, record: dict[str, Any]) -> None: f"selected manifest artifact {name} version {artifact_version!r} " f"does not match {version}" ) + identifier_errors = identifier_catalog_errors( + version, + data.get("identifier_catalog"), + repo, + ) + if identifier_errors: + raise ReleasePlanError( + f"selected manifest {identifier_errors[0]}" + ) def validate_workspace_versions(repo: Path, version: str) -> dict[str, Any]: @@ -2363,6 +2441,13 @@ def prepare_changes(context: dict[str, Any]) -> list[dict[str, Any]]: (repo / "Cargo.lock", "workspace-lock"), (context["documents"]["release_note"], "release-note"), ] + identifier_catalog = context["selected"]["data"].get("identifier_catalog") + if isinstance(identifier_catalog, dict) and isinstance( + identifier_catalog.get("path"), str + ): + surfaces.append( + (repo / identifier_catalog["path"], "identifier-catalog") + ) if context["uses_release_docs"]: surfaces.extend( ( diff --git a/release/scripts/test_registry_release.py b/release/scripts/test_registry_release.py index 8ed1e577b..2b116f0d3 100755 --- a/release/scripts/test_registry_release.py +++ b/release/scripts/test_registry_release.py @@ -2,6 +2,7 @@ from __future__ import annotations import importlib.util +import hashlib import io import json import stat @@ -463,6 +464,7 @@ def test_required_rust_context_aggregates_path_gated_shards(self) -> None: "rust-quality", "rust-tests", "evidence-contracts", + "identifiers", "relay-contracts", "relay-v2-contracts", }, @@ -2478,6 +2480,33 @@ def test_validate_v0_19_does_not_require_registryctl_artifacts(self) -> None: self.assertNotIn("registryctl-installer", data["artifacts"]) self.assertNotIn("registry-docs", data["artifacts"]) + def test_validate_requires_exact_identifier_catalog_after_v0_19_0(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + manifest = write_manifest(root, version="0.19.1") + accepted = run_tool("validate", str(manifest)) + + data = yaml.safe_load(manifest.read_text(encoding="utf-8")) + data["identifier_catalog"]["sha256"] = "0" * 64 + manifest.write_text( + yaml.safe_dump(data, sort_keys=False), encoding="utf-8" + ) + mismatched = run_tool("validate", str(manifest)) + + missing = write_manifest(root, version="0.19.1") + data = yaml.safe_load(missing.read_text(encoding="utf-8")) + del data["identifier_catalog"] + missing.write_text( + yaml.safe_dump(data, sort_keys=False), encoding="utf-8" + ) + absent = run_tool("validate", str(missing)) + + self.assertEqual(0, accepted.returncode, accepted.stderr) + self.assertNotEqual(0, mismatched.returncode) + self.assertIn("does not match the committed catalog bytes", mismatched.stderr) + self.assertNotEqual(0, absent.returncode) + self.assertIn("identifier_catalog is required", absent.stderr) + def test_relay_installer_joins_the_exact_inventory_after_v0_19_0(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) @@ -4341,6 +4370,14 @@ def write_manifest( }, }, } + if version_tuple >= (0, 19, 1): + catalog_path = ROOT / "products/identifiers/generated/catalog.v1.json" + catalog = json.loads(catalog_path.read_text(encoding="utf-8")) + manifest["identifier_catalog"] = { + "path": "products/identifiers/generated/catalog.v1.json", + "sha256": hashlib.sha256(catalog_path.read_bytes()).hexdigest(), + "entry_count": len(catalog["entries"]), + } path = directory / "release-manifest.yaml" path.write_text(yaml.safe_dump(manifest, sort_keys=False), encoding="utf-8") return path diff --git a/release/scripts/test_registry_release_plans.py b/release/scripts/test_registry_release_plans.py index 1af51efe3..951e55005 100644 --- a/release/scripts/test_registry_release_plans.py +++ b/release/scripts/test_registry_release_plans.py @@ -14,6 +14,13 @@ ROOT = Path(__file__).resolve().parents[2] TOOL = ROOT / "release/scripts/registry-release" CROSSWALK_REF = "1" * 40 +FIXTURE_IDENTIFIER_CATALOG = { + "version": 1, + "entries": [{"status": "active"}], +} +FIXTURE_IDENTIFIER_CATALOG_SHA256 = hashlib.sha256( + (json.dumps(FIXTURE_IDENTIFIER_CATALOG, indent=2) + "\n").encode() +).hexdigest() LEGACY_ARTIFACT_INVENTORY = ( "evidence", "evidence-client-node", @@ -88,7 +95,7 @@ def manifest(version: str, release_id: str, source_ref: str, status: str) -> dic if version_tuple >= (0, 19, 0) else LEGACY_ARTIFACT_INVENTORY ) - return { + data = { "stack": { "release": release_id, "version": version, @@ -106,6 +113,13 @@ def manifest(version: str, release_id: str, source_ref: str, status: str) -> dic } }, } + if version_tuple >= (0, 19, 1): + data["identifier_catalog"] = { + "path": "products/identifiers/generated/catalog.v1.json", + "sha256": FIXTURE_IDENTIFIER_CATALOG_SHA256, + "entry_count": len(FIXTURE_IDENTIFIER_CATALOG["entries"]), + } + return data class FixtureRepo: @@ -138,6 +152,10 @@ def __init__(self, root: Path) -> None: def _write_surfaces(self) -> None: root = self.root + write_json( + root / "products/identifiers/generated/catalog.v1.json", + FIXTURE_IDENTIFIER_CATALOG, + ) write( root / "Cargo.toml", f'''[workspace] @@ -635,6 +653,24 @@ def test_prepare_rejects_missing_required_artifacts(self) -> None: ) self.assertIn("unexpected registry-docs", incomplete_inventory.stderr) + def test_prepare_rejects_identifier_catalog_drift(self) -> None: + write_json( + self.repo.root / "products/identifiers/generated/catalog.v1.json", + { + "version": 1, + "entries": [{"status": "active"}, {"status": "active"}], + }, + ) + + result = self.prepare() + + self.assertEqual(1, result.returncode) + self.assertEqual("", result.stdout) + self.assertIn( + "identifier_catalog.sha256 does not match the committed catalog bytes", + result.stderr, + ) + def test_prepare_rejects_stale_registryctl_lock_version(self) -> None: lock = self.repo.root / "Cargo.lock" lock.write_text( From 9e36e67c63b95a1234853417ef234c14b394b1f6 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 11 Aug 2026 15:24:04 +0700 Subject: [PATCH 2/2] fix: close identifier catalog review gaps Signed-off-by: Jeremi Joslin --- .github/scripts/ci_changes.py | 3 + .github/scripts/test_ci_changes.py | 13 ++ products/identifiers/scripts/generate.py | 125 +++++++++++++++++- products/identifiers/scripts/test_generate.py | 14 ++ release/scripts/registry-release | 54 +++++++- release/scripts/test_registry_release.py | 60 ++++++++- .../scripts/test_registry_release_plans.py | 30 ++++- 7 files changed, 287 insertions(+), 12 deletions(-) diff --git a/.github/scripts/ci_changes.py b/.github/scripts/ci_changes.py index ac8159ec5..7f67c3f71 100644 --- a/.github/scripts/ci_changes.py +++ b/.github/scripts/ci_changes.py @@ -190,6 +190,9 @@ def identifier_catalog_inputs( inputs = [ "products/identifiers/**", + "crates/registry-relay-v2/examples/audit-event-schema.rs", + "crates/registry-relay-v2/examples/problem-catalog.rs", + "crates/registry-relay-v2/src/audit.rs", "crates/registry-relay-v2/src/problem.rs", ] for index, group in enumerate(schema_groups): diff --git a/.github/scripts/test_ci_changes.py b/.github/scripts/test_ci_changes.py index f9b7baaea..0d3998198 100644 --- a/.github/scripts/test_ci_changes.py +++ b/.github/scripts/test_ci_changes.py @@ -239,6 +239,19 @@ def test_every_identifier_source_selects_the_catalog_gate(self) -> None: with self.subTest(pattern=pattern): self.assertTrue(classify(self.workspace, (sample,))["identifiers"]) + def test_identifier_exporters_and_indirect_inputs_select_the_catalog_gate( + self, + ) -> None: + for path in ( + "crates/registry-relay-v2/examples/audit-event-schema.rs", + "crates/registry-relay-v2/examples/problem-catalog.rs", + "crates/registry-relay-v2/src/artifacts.rs", + "crates/registry-relay-v2/src/audit.rs", + "crates/registry-relay-v2/src/problem.rs", + ): + with self.subTest(path=path): + self.assertTrue(classify(self.workspace, (path,))["identifiers"]) + def test_ci_always_checks_repository_identifier_reference_closure(self) -> None: workflow = Path(".github/workflows/ci.yml").read_text(encoding="utf-8") self.assertIn( diff --git a/products/identifiers/scripts/generate.py b/products/identifiers/scripts/generate.py index be0e4768e..e55582698 100755 --- a/products/identifiers/scripts/generate.py +++ b/products/identifiers/scripts/generate.py @@ -375,6 +375,127 @@ def validate_entries(repo_root: Path, entries: list[dict[str, Any]]) -> None: raise CatalogError(f"schema $id and catalog URI disagree: {uri}") +def validate_catalog_contract(catalog: dict[str, Any]) -> None: + if set(catalog) != {"version", "baseUrl", "entries"}: + raise CatalogError("generated catalog has an invalid top-level shape") + if catalog["version"] != 1 or catalog["baseUrl"] != BASE_URL: + raise CatalogError("generated catalog has an invalid version or base URL") + entries = catalog["entries"] + if not isinstance(entries, list): + raise CatalogError("generated catalog entries must be an array") + + common_required = { + "uri", + "kind", + "status", + "compatibilityLine", + "owner", + "title", + "description", + "source", + } + allowed = common_required | {"artifact", "problem"} + valid_kinds = { + "problem", + "schema", + "context", + "namespace", + "vocabulary", + "vocabulary-term", + } + digest_pattern = re.compile(r"^[0-9a-f]{64}$") + + for index, entry in enumerate(entries): + if not isinstance(entry, dict): + raise CatalogError(f"generated catalog entry {index} must be an object") + if not common_required.issubset(entry) or not set(entry).issubset(allowed): + raise CatalogError(f"generated catalog entry {index} has an invalid shape") + for field in ( + "uri", + "compatibilityLine", + "owner", + "title", + "description", + ): + if not isinstance(entry[field], str) or not entry[field]: + raise CatalogError( + f"generated catalog entry {index} has an invalid {field}" + ) + if not entry["uri"].startswith(f"{BASE_URL}/"): + raise CatalogError(f"generated catalog entry {index} has an invalid uri") + if entry["kind"] not in valid_kinds or entry["status"] != "active": + raise CatalogError( + f"generated catalog entry {index} has an invalid kind or status" + ) + + source = entry["source"] + if not isinstance(source, dict) or set(source) != {"path", "sha256"}: + raise CatalogError(f"generated catalog entry {index} has an invalid source") + if not isinstance(source["path"], str) or not source["path"]: + raise CatalogError(f"generated catalog entry {index} has an invalid source path") + if not isinstance(source["sha256"], str) or digest_pattern.fullmatch( + source["sha256"] + ) is None: + raise CatalogError( + f"generated catalog entry {index} has an invalid source digest" + ) + + artifact = entry.get("artifact") + if entry["kind"] in {"schema", "context"} and artifact is None: + raise CatalogError(f"generated catalog entry {index} requires an artifact") + if artifact is not None: + if not isinstance(artifact, dict) or set(artifact) != { + "path", + "sha256", + "mediaType", + }: + raise CatalogError( + f"generated catalog entry {index} has an invalid artifact" + ) + for field in ("path", "mediaType"): + if not isinstance(artifact[field], str) or not artifact[field]: + raise CatalogError( + f"generated catalog entry {index} has an invalid artifact {field}" + ) + if not isinstance(artifact["sha256"], str) or digest_pattern.fullmatch( + artifact["sha256"] + ) is None: + raise CatalogError( + f"generated catalog entry {index} has an invalid artifact digest" + ) + + problem = entry.get("problem") + if entry["kind"] == "problem" and problem is None: + raise CatalogError(f"generated catalog entry {index} requires problem facts") + if problem is not None: + if not isinstance(problem, dict) or set(problem) != { + "code", + "httpStatuses", + }: + raise CatalogError( + f"generated catalog entry {index} has invalid problem facts" + ) + if not isinstance(problem["code"], str) or not problem["code"]: + raise CatalogError( + f"generated catalog entry {index} has an invalid problem code" + ) + statuses = problem["httpStatuses"] + if not isinstance(statuses, list) or not statuses: + invalid_statuses = True + else: + invalid_statuses = any( + isinstance(status, bool) + or not isinstance(status, int) + or status < 100 + or status > 599 + for status in statuses + ) or len(statuses) != len(set(statuses)) + if invalid_statuses: + raise CatalogError( + f"generated catalog entry {index} has invalid HTTP statuses" + ) + + def reference_uris(path: Path) -> set[str]: try: text = path.read_text(encoding="utf-8") @@ -438,13 +559,15 @@ def build_catalog( ] entries.sort(key=lambda entry: entry["uri"]) validate_entries(repo_root, entries) + catalog = {"version": 1, "baseUrl": BASE_URL, "entries": entries} + validate_catalog_contract(catalog) validate_reference_closure( repo_root, entries, config["referenceExclusions"], repository_paths, ) - return {"version": 1, "baseUrl": BASE_URL, "entries": entries} + return catalog def render(catalog: dict[str, Any]) -> bytes: diff --git a/products/identifiers/scripts/test_generate.py b/products/identifiers/scripts/test_generate.py index 038bdfadc..4817a97a8 100644 --- a/products/identifiers/scripts/test_generate.py +++ b/products/identifiers/scripts/test_generate.py @@ -162,6 +162,20 @@ def test_retired_source_group_is_rejected(self) -> None: with self.assertRaisesRegex(generate.CatalogError, "invalid status"): self.build() + def test_catalog_contract_rejects_blank_owner(self) -> None: + document = json.loads(self.config.read_text()) + document["schemaSources"][0]["owner"] = "" + self.config.write_text(json.dumps(document)) + with self.assertRaisesRegex(generate.CatalogError, "invalid owner"): + self.build() + + def test_catalog_contract_rejects_invalid_problem_statuses(self) -> None: + document = json.loads(self.problem_catalog.read_text()) + document["entries"][0]["httpStatuses"] = [99, 99] + self.problem_catalog.write_text(json.dumps(document)) + with self.assertRaisesRegex(generate.CatalogError, "invalid HTTP statuses"): + self.build() + def test_unclassified_identifier_reference_is_rejected(self) -> None: (self.root / "src" / "unclassified.rs").write_text( f'const URI: &str = "{generate.BASE_URL}/unclassified/value";\n' diff --git a/release/scripts/registry-release b/release/scripts/registry-release index 61ae9d9b2..b804e29d8 100755 --- a/release/scripts/registry-release +++ b/release/scripts/registry-release @@ -227,6 +227,7 @@ def identifier_catalog_errors( version: str, binding: Any, repo_root: Path = ROOT, + source_ref: str | None = None, ) -> list[str]: parsed_version = release_version_tuple(version) required = ( @@ -256,17 +257,39 @@ def identifier_catalog_errors( entry_count = binding.get("entry_count") if not isinstance(entry_count, int) or isinstance(entry_count, bool) or entry_count < 1: errors.append("identifier_catalog.entry_count must be a positive integer") + catalog_bytes: bytes | None = None catalog_path = repo_root / IDENTIFIER_CATALOG_RELATIVE_PATH - if not errors and not catalog_path.is_file(): - errors.append("the committed identifier catalog is missing") + if not errors and source_ref is not None and HEX40.fullmatch(source_ref): + rendered = subprocess.run( + [ + "git", + "show", + f"{source_ref}:{IDENTIFIER_CATALOG_RELATIVE_PATH}", + ], + cwd=repo_root, + capture_output=True, + check=False, + ) + if rendered.returncode != 0: + errors.append( + "the identifier catalog is missing from the recorded stack.source_ref" + ) + else: + catalog_bytes = rendered.stdout + elif not errors: + try: + catalog_bytes = catalog_path.read_bytes() + except OSError: + errors.append("the committed identifier catalog is missing") if not errors: - if sha256(catalog_path) != digest_value: + assert catalog_bytes is not None + if hashlib.sha256(catalog_bytes).hexdigest() != digest_value: errors.append( "identifier_catalog.sha256 does not match the committed catalog bytes" ) try: - catalog = json.loads(catalog_path.read_text(encoding="utf-8")) - except (OSError, UnicodeError, json.JSONDecodeError): + catalog = json.loads(catalog_bytes.decode("utf-8")) + except (UnicodeError, json.JSONDecodeError): errors.append("committed identifier catalog is not readable JSON") else: entries = catalog.get("entries") if isinstance(catalog, dict) else None @@ -676,7 +699,25 @@ def validate(manifest_path: Path) -> int: "stack.source_ref may be HEAD only when stack.status is draft" ) errors.extend(artifact_inventory_errors(version, artifacts)) - errors.extend(identifier_catalog_errors(version, identifier_catalog)) + repository = subprocess.run( + ["git", "-C", str(manifest_path.parent), "rev-parse", "--show-toplevel"], + text=True, + capture_output=True, + check=False, + ) + repo_root = ( + Path(repository.stdout.strip()) + if repository.returncode == 0 and repository.stdout.strip() + else ROOT + ) + errors.extend( + identifier_catalog_errors( + version, + identifier_catalog, + repo_root, + source_ref if has_source_ref else None, + ) + ) for name, artifact_version in sorted(artifacts.items()): if str(artifact_version) != version: errors.append(f"artifact {name} version {artifact_version} does not match stack version {version}") @@ -1923,6 +1964,7 @@ def validate_selected_manifest(repo: Path, record: dict[str, Any]) -> None: version, data.get("identifier_catalog"), repo, + source_ref if isinstance(source_ref, str) else None, ) if identifier_errors: raise ReleasePlanError( diff --git a/release/scripts/test_registry_release.py b/release/scripts/test_registry_release.py index 2b116f0d3..6e641b84d 100755 --- a/release/scripts/test_registry_release.py +++ b/release/scripts/test_registry_release.py @@ -2483,7 +2483,12 @@ def test_validate_v0_19_does_not_require_registryctl_artifacts(self) -> None: def test_validate_requires_exact_identifier_catalog_after_v0_19_0(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) - manifest = write_manifest(root, version="0.19.1") + catalog_source_ref = git(ROOT, "rev-parse", "HEAD") + manifest = write_manifest( + root, + version="0.19.1", + source_ref=catalog_source_ref, + ) accepted = run_tool("validate", str(manifest)) data = yaml.safe_load(manifest.read_text(encoding="utf-8")) @@ -2493,7 +2498,11 @@ def test_validate_requires_exact_identifier_catalog_after_v0_19_0(self) -> None: ) mismatched = run_tool("validate", str(manifest)) - missing = write_manifest(root, version="0.19.1") + missing = write_manifest( + root, + version="0.19.1", + source_ref=catalog_source_ref, + ) data = yaml.safe_load(missing.read_text(encoding="utf-8")) del data["identifier_catalog"] missing.write_text( @@ -2507,13 +2516,58 @@ def test_validate_requires_exact_identifier_catalog_after_v0_19_0(self) -> None: self.assertNotEqual(0, absent.returncode) self.assertIn("identifier_catalog is required", absent.stderr) + def test_validate_uses_identifier_catalog_from_recorded_source_ref(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = init_repo(Path(tmp)) + catalog_path = ( + root / "products/identifiers/generated/catalog.v1.json" + ) + catalog_path.parent.mkdir(parents=True) + source_catalog = ( + ROOT / "products/identifiers/generated/catalog.v1.json" + ).read_bytes() + catalog_path.write_bytes(source_catalog) + git(root, "add", str(catalog_path.relative_to(root))) + git(root, "commit", "-m", "record release catalog") + source_ref = git(root, "rev-parse", "HEAD") + manifest = write_manifest( + root, + version="0.19.1", + source_ref=source_ref, + status="released", + ) + + catalog_path.write_text( + json.dumps({"version": 1, "entries": [{"status": "active"}]}) + + "\n", + encoding="utf-8", + ) + accepted = run_tool("validate", str(manifest)) + + data = yaml.safe_load(manifest.read_text(encoding="utf-8")) + data["identifier_catalog"]["sha256"] = hashlib.sha256( + catalog_path.read_bytes() + ).hexdigest() + manifest.write_text( + yaml.safe_dump(data, sort_keys=False), encoding="utf-8" + ) + mismatched = run_tool("validate", str(manifest)) + + self.assertEqual(0, accepted.returncode, accepted.stderr) + self.assertNotEqual(0, mismatched.returncode) + self.assertIn("does not match the committed catalog bytes", mismatched.stderr) + def test_relay_installer_joins_the_exact_inventory_after_v0_19_0(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) historical = write_manifest(root, version="0.19.0") historical_result = run_tool("validate", str(historical)) - current = write_manifest(root, version="0.19.1") + current = write_manifest( + root, + version="0.19.1", + source_ref=git(ROOT, "rev-parse", "HEAD"), + ) current_result = run_tool("validate", str(current)) data = yaml.safe_load(current.read_text(encoding="utf-8")) del data["artifacts"]["relay-installer"] diff --git a/release/scripts/test_registry_release_plans.py b/release/scripts/test_registry_release_plans.py index 951e55005..0d63d723f 100644 --- a/release/scripts/test_registry_release_plans.py +++ b/release/scripts/test_registry_release_plans.py @@ -130,7 +130,11 @@ def __init__(self, root: Path) -> None: git(root, "config", "user.email", "release-test@example.invalid") git(root, "config", "user.name", "Release Test") write(root / "seed", "candidate\n") - git(root, "add", "seed") + write_json( + root / "products/identifiers/generated/catalog.v1.json", + FIXTURE_IDENTIFIER_CATALOG, + ) + git(root, "add", "seed", "products/identifiers/generated/catalog.v1.json") git(root, "commit", "-m", "candidate") self.candidate = git(root, "rev-parse", "HEAD") git(root, "tag", "v1.0.0") @@ -653,7 +657,7 @@ def test_prepare_rejects_missing_required_artifacts(self) -> None: ) self.assertIn("unexpected registry-docs", incomplete_inventory.stderr) - def test_prepare_rejects_identifier_catalog_drift(self) -> None: + def test_prepare_uses_identifier_catalog_from_recorded_source_ref(self) -> None: write_json( self.repo.root / "products/identifiers/generated/catalog.v1.json", { @@ -664,6 +668,28 @@ def test_prepare_rejects_identifier_catalog_drift(self) -> None: result = self.prepare() + self.assertEqual(0, result.returncode, result.stderr) + + def test_prepare_rejects_identifier_catalog_drift_at_recorded_source_ref( + self, + ) -> None: + write_json( + self.repo.root / "products/identifiers/generated/catalog.v1.json", + { + "version": 1, + "entries": [{"status": "active"}, {"status": "active"}], + }, + ) + git(self.repo.root, "add", "products/identifiers/generated/catalog.v1.json") + git(self.repo.root, "commit", "-m", "change release catalog") + changed_source = git(self.repo.root, "rev-parse", "HEAD") + target = self.repo.root / "release/manifests/registry-stack-beta-9.yaml" + data = yaml.safe_load(target.read_text(encoding="utf-8")) + data["stack"]["source_ref"] = changed_source + write_yaml(target, data) + + result = self.prepare() + self.assertEqual(1, result.returncode) self.assertEqual("", result.stdout) self.assertIn(