From 5aedc92d5c0a36d14adac4e46ff804f9af1be689 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 10 Aug 2026 03:04:11 +0700 Subject: [PATCH 01/24] feat(relay): add Relay V2 Signed-off-by: Jeremi Joslin --- .github/scripts/ci_changes.py | 8 +- .github/scripts/test_ci_changes.py | 23 + .github/workflows/ci.yml | 32 + AGENTS.md | 8 + Cargo.lock | 68 + Cargo.toml | 6 + crates/registry-evidence/Cargo.toml | 1 + crates/registry-evidence/src/bundle.rs | 129 +- crates/registry-evidence/src/source.rs | 22 +- crates/registry-evidence/src/source_sqlite.rs | 1164 ++----- .../tests/statement_source.rs | 6 + crates/registry-platform-sqlite/Cargo.toml | 31 + crates/registry-platform-sqlite/README.md | 39 + .../registry-platform-sqlite/src/capture.rs | 283 ++ crates/registry-platform-sqlite/src/error.rs | 153 + crates/registry-platform-sqlite/src/lib.rs | 25 + crates/registry-platform-sqlite/src/schema.rs | 396 +++ .../registry-platform-sqlite/src/statement.rs | 1169 +++++++ .../registry-platform-sqlite/tests/kernel.rs | 462 +++ crates/registry-relay-v2/Cargo.toml | 68 + crates/registry-relay-v2/README.md | 16 + crates/registry-relay-v2/src/api.rs | 2517 +++++++++++++++ crates/registry-relay-v2/src/artifacts.rs | 973 ++++++ crates/registry-relay-v2/src/audit.rs | 235 ++ crates/registry-relay-v2/src/auth.rs | 609 ++++ crates/registry-relay-v2/src/compiler.rs | 2728 +++++++++++++++++ crates/registry-relay-v2/src/contract.rs | 792 +++++ crates/registry-relay-v2/src/cursor.rs | 276 ++ crates/registry-relay-v2/src/diff.rs | 954 ++++++ crates/registry-relay-v2/src/fixtures.rs | 903 ++++++ crates/registry-relay-v2/src/lib.rs | 27 + crates/registry-relay-v2/src/main.rs | 100 + crates/registry-relay-v2/src/model.rs | 354 +++ crates/registry-relay-v2/src/package.rs | 851 +++++ crates/registry-relay-v2/src/problem.rs | 406 +++ crates/registry-relay-v2/src/semantics.rs | 418 +++ crates/registry-relay-v2/src/server.rs | 371 +++ .../src/source_observation.rs | 90 + .../registry-relay-v2/src/sqlite_runtime.rs | 572 ++++ crates/registry-relay-v2/src/startup.rs | 1057 +++++++ crates/registry-relay-v2/src/tooling.rs | 1115 +++++++ .../tests/acceptance_http.rs | 1572 ++++++++++ .../tests/multi_resource_isolation.rs | 1119 +++++++ .../registry-relay-v2/tests/process_http.rs | 262 ++ crates/registry-relayctl/Cargo.toml | 24 + crates/registry-relayctl/INTEGRATION.md | 34 + crates/registry-relayctl/src/lib.rs | 332 ++ crates/registry-relayctl/src/main.rs | 7 + crates/registry-relayctl/src/shared.rs | 41 + .../registry-relayctl/tests/cli_contract.rs | 61 + products/platform/fuzz/Cargo.lock | 80 + products/platform/fuzz/Cargo.toml | 8 + products/platform/fuzz/README.md | 2 + .../sqlite_statement/multiple-statements | 1 + .../corpus/sqlite_statement/positional-alias | 1 + .../quoted-and-commented-parameters | 2 + .../fuzz/corpus/sqlite_statement/select-valid | 1 + .../fuzz/fuzz_targets/sqlite_statement.rs | 37 + products/relay-v2/CONCEPT.md | 674 ++++ products/relay-v2/CONFIGURATION-EXAMPLES.md | 902 ++++++ products/relay-v2/DEFINITION-OF-DONE.md | 172 ++ products/relay-v2/IMPLEMENTATION.md | 699 +++++ products/relay-v2/README.md | 51 + products/relay-v2/STANDARDS-ALIGNMENT.md | 53 + .../codelists/business-status.yaml | 4 + .../codelists/jurisdictions.yaml | 4 + .../codelists/legal-forms.yaml | 4 + .../codelists/record-lifecycle.yaml | 4 + .../business-registry/expected-http.yaml | 128 + .../acceptance/business-registry/fixture.sql | 30 + .../governance/identifier-lifecycle.yaml | 7 + .../governance/legal-basis.yaml | 4 + .../business-registry/registry.yaml | 131 + .../acceptance/business-registry/runtime.yaml | 24 + .../semantics/local-vocabulary.yaml | 12 + .../semantics/semic-business-alignment.yaml | 8 + .../codelists/civil-event-types.yaml | 4 + .../codelists/record-lifecycle.yaml | 4 + .../codelists/registration-areas.yaml | 4 + .../codelists/registration-status.yaml | 4 + .../acceptance/civil-event/expected-http.yaml | 170 + .../acceptance/civil-event/fixture.sql | 36 + .../governance/identifier-lifecycle.yaml | 7 + .../civil-event/governance/legal-basis.yaml | 4 + .../acceptance/civil-event/registry.yaml | 166 + .../acceptance/civil-event/runtime.yaml | 24 + .../semantics/local-vocabulary.yaml | 13 + .../publicschema-event-alignment.yaml | 7 + .../codelists/enrolment-status.yaml | 4 + .../codelists/programmes.yaml | 4 + .../codelists/record-lifecycle.yaml | 4 + .../social-assistance/expected-http.yaml | 176 ++ .../acceptance/social-assistance/fixture.sql | 35 + .../governance/identifier-lifecycle.yaml | 7 + .../governance/legal-basis.yaml | 4 + .../social-assistance/registry.yaml | 127 + .../acceptance/social-assistance/runtime.yaml | 24 + .../semantics/local-vocabulary.yaml | 14 + .../contracts/acceptance-scenario-matrix.yaml | 63 + .../contracts/artifact-inventory.yaml | 62 + .../contracts/generated-baselines.yaml | 600 ++++ .../relay-v2/contracts/package-layout.yaml | 37 + .../contracts/security-invariant-matrix.yaml | 169 + products/relay-v2/scripts/check-configs.sh | 5 + products/relay-v2/scripts/check-contracts.sh | 12 + .../scripts/check-exposure-inventory.sh | 5 + products/relay-v2/scripts/check-generated.sh | 26 + .../scripts/check-source-neutrality.sh | 32 + products/relay-v2/scripts/test-http.sh | 11 + .../relay-v2/scripts/test_adopter_workflow.py | 392 +++ .../relay-v2/scripts/test_validate_product.py | 129 + products/relay-v2/scripts/validate_product.py | 327 ++ release/docker/Dockerfile.relay | 35 + release/notes/unsafe-code-inventory.md | 112 +- release/scripts/check-debian13-images.py | 25 + release/scripts/check-gates-inventory.py | 2 +- release/scripts/check-release-source-model.sh | 2 + release/scripts/test_check_debian13_images.py | 52 + release/scripts/test_check_gates_inventory.py | 2 +- .../test_check_release_source_model.py | 2 + release/scripts/test_registry_release.py | 1 + 121 files changed, 27869 insertions(+), 1028 deletions(-) create mode 100644 crates/registry-platform-sqlite/Cargo.toml create mode 100644 crates/registry-platform-sqlite/README.md create mode 100644 crates/registry-platform-sqlite/src/capture.rs create mode 100644 crates/registry-platform-sqlite/src/error.rs create mode 100644 crates/registry-platform-sqlite/src/lib.rs create mode 100644 crates/registry-platform-sqlite/src/schema.rs create mode 100644 crates/registry-platform-sqlite/src/statement.rs create mode 100644 crates/registry-platform-sqlite/tests/kernel.rs create mode 100644 crates/registry-relay-v2/Cargo.toml create mode 100644 crates/registry-relay-v2/README.md create mode 100644 crates/registry-relay-v2/src/api.rs create mode 100644 crates/registry-relay-v2/src/artifacts.rs create mode 100644 crates/registry-relay-v2/src/audit.rs create mode 100644 crates/registry-relay-v2/src/auth.rs create mode 100644 crates/registry-relay-v2/src/compiler.rs create mode 100644 crates/registry-relay-v2/src/contract.rs create mode 100644 crates/registry-relay-v2/src/cursor.rs create mode 100644 crates/registry-relay-v2/src/diff.rs create mode 100644 crates/registry-relay-v2/src/fixtures.rs create mode 100644 crates/registry-relay-v2/src/lib.rs create mode 100644 crates/registry-relay-v2/src/main.rs create mode 100644 crates/registry-relay-v2/src/model.rs create mode 100644 crates/registry-relay-v2/src/package.rs create mode 100644 crates/registry-relay-v2/src/problem.rs create mode 100644 crates/registry-relay-v2/src/semantics.rs create mode 100644 crates/registry-relay-v2/src/server.rs create mode 100644 crates/registry-relay-v2/src/source_observation.rs create mode 100644 crates/registry-relay-v2/src/sqlite_runtime.rs create mode 100644 crates/registry-relay-v2/src/startup.rs create mode 100644 crates/registry-relay-v2/src/tooling.rs create mode 100644 crates/registry-relay-v2/tests/acceptance_http.rs create mode 100644 crates/registry-relay-v2/tests/multi_resource_isolation.rs create mode 100644 crates/registry-relay-v2/tests/process_http.rs create mode 100644 crates/registry-relayctl/Cargo.toml create mode 100644 crates/registry-relayctl/INTEGRATION.md create mode 100644 crates/registry-relayctl/src/lib.rs create mode 100644 crates/registry-relayctl/src/main.rs create mode 100644 crates/registry-relayctl/src/shared.rs create mode 100644 crates/registry-relayctl/tests/cli_contract.rs create mode 100644 products/platform/fuzz/corpus/sqlite_statement/multiple-statements create mode 100644 products/platform/fuzz/corpus/sqlite_statement/positional-alias create mode 100644 products/platform/fuzz/corpus/sqlite_statement/quoted-and-commented-parameters create mode 100644 products/platform/fuzz/corpus/sqlite_statement/select-valid create mode 100644 products/platform/fuzz/fuzz_targets/sqlite_statement.rs create mode 100644 products/relay-v2/CONCEPT.md create mode 100644 products/relay-v2/CONFIGURATION-EXAMPLES.md create mode 100644 products/relay-v2/DEFINITION-OF-DONE.md create mode 100644 products/relay-v2/IMPLEMENTATION.md create mode 100644 products/relay-v2/README.md create mode 100644 products/relay-v2/STANDARDS-ALIGNMENT.md create mode 100644 products/relay-v2/acceptance/business-registry/codelists/business-status.yaml create mode 100644 products/relay-v2/acceptance/business-registry/codelists/jurisdictions.yaml create mode 100644 products/relay-v2/acceptance/business-registry/codelists/legal-forms.yaml create mode 100644 products/relay-v2/acceptance/business-registry/codelists/record-lifecycle.yaml create mode 100644 products/relay-v2/acceptance/business-registry/expected-http.yaml create mode 100644 products/relay-v2/acceptance/business-registry/fixture.sql create mode 100644 products/relay-v2/acceptance/business-registry/governance/identifier-lifecycle.yaml create mode 100644 products/relay-v2/acceptance/business-registry/governance/legal-basis.yaml create mode 100644 products/relay-v2/acceptance/business-registry/registry.yaml create mode 100644 products/relay-v2/acceptance/business-registry/runtime.yaml create mode 100644 products/relay-v2/acceptance/business-registry/semantics/local-vocabulary.yaml create mode 100644 products/relay-v2/acceptance/business-registry/semantics/semic-business-alignment.yaml create mode 100644 products/relay-v2/acceptance/civil-event/codelists/civil-event-types.yaml create mode 100644 products/relay-v2/acceptance/civil-event/codelists/record-lifecycle.yaml create mode 100644 products/relay-v2/acceptance/civil-event/codelists/registration-areas.yaml create mode 100644 products/relay-v2/acceptance/civil-event/codelists/registration-status.yaml create mode 100644 products/relay-v2/acceptance/civil-event/expected-http.yaml create mode 100644 products/relay-v2/acceptance/civil-event/fixture.sql create mode 100644 products/relay-v2/acceptance/civil-event/governance/identifier-lifecycle.yaml create mode 100644 products/relay-v2/acceptance/civil-event/governance/legal-basis.yaml create mode 100644 products/relay-v2/acceptance/civil-event/registry.yaml create mode 100644 products/relay-v2/acceptance/civil-event/runtime.yaml create mode 100644 products/relay-v2/acceptance/civil-event/semantics/local-vocabulary.yaml create mode 100644 products/relay-v2/acceptance/civil-event/semantics/publicschema-event-alignment.yaml create mode 100644 products/relay-v2/acceptance/social-assistance/codelists/enrolment-status.yaml create mode 100644 products/relay-v2/acceptance/social-assistance/codelists/programmes.yaml create mode 100644 products/relay-v2/acceptance/social-assistance/codelists/record-lifecycle.yaml create mode 100644 products/relay-v2/acceptance/social-assistance/expected-http.yaml create mode 100644 products/relay-v2/acceptance/social-assistance/fixture.sql create mode 100644 products/relay-v2/acceptance/social-assistance/governance/identifier-lifecycle.yaml create mode 100644 products/relay-v2/acceptance/social-assistance/governance/legal-basis.yaml create mode 100644 products/relay-v2/acceptance/social-assistance/registry.yaml create mode 100644 products/relay-v2/acceptance/social-assistance/runtime.yaml create mode 100644 products/relay-v2/acceptance/social-assistance/semantics/local-vocabulary.yaml create mode 100644 products/relay-v2/contracts/acceptance-scenario-matrix.yaml create mode 100644 products/relay-v2/contracts/artifact-inventory.yaml create mode 100644 products/relay-v2/contracts/generated-baselines.yaml create mode 100644 products/relay-v2/contracts/package-layout.yaml create mode 100644 products/relay-v2/contracts/security-invariant-matrix.yaml create mode 100755 products/relay-v2/scripts/check-configs.sh create mode 100755 products/relay-v2/scripts/check-contracts.sh create mode 100755 products/relay-v2/scripts/check-exposure-inventory.sh create mode 100755 products/relay-v2/scripts/check-generated.sh create mode 100755 products/relay-v2/scripts/check-source-neutrality.sh create mode 100755 products/relay-v2/scripts/test-http.sh create mode 100755 products/relay-v2/scripts/test_adopter_workflow.py create mode 100644 products/relay-v2/scripts/test_validate_product.py create mode 100644 products/relay-v2/scripts/validate_product.py create mode 100644 release/docker/Dockerfile.relay create mode 100644 release/scripts/test_check_debian13_images.py diff --git a/.github/scripts/ci_changes.py b/.github/scripts/ci_changes.py index bda793d5a..022cf047b 100644 --- a/.github/scripts/ci_changes.py +++ b/.github/scripts/ci_changes.py @@ -25,6 +25,7 @@ "registry-platform-ops", "registry-platform-pdp", "registry-platform-sdjwt", + "registry-platform-sqlite", "registry-platform-testing", ), "manifest": ( @@ -32,6 +33,7 @@ "registry-manifest-core", ), "relay": ("registry-relay",), + "relay-v2": ("registry-relay-v2", "registry-relayctl"), "evidence": ( "registry-evidence", "registry-evidence-authoring", @@ -53,6 +55,7 @@ EVIDENCE_PACKAGES = frozenset(SHARDS["evidence"]) PLATFORM_PACKAGES = frozenset(SHARDS["platform"]) MANIFEST_PACKAGES = frozenset(SHARDS["manifest"]) +RELAY_V2_PACKAGES = frozenset(SHARDS["relay-v2"]) TUTORIAL_PACKAGES = frozenset( package for shard in ("platform", "manifest", "relay", "registryctl") @@ -398,6 +401,8 @@ def classify( seeds.update(MANIFEST_PACKAGES) elif path.startswith("products/platform/"): seeds.update(PLATFORM_PACKAGES) + elif path.startswith("products/relay-v2/"): + seeds.update(RELAY_V2_PACKAGES) elif path in { "docs/site/src/data/generated/relay-support.json", "docs/site/src/data/relay-support.yaml", @@ -615,7 +620,7 @@ def classify( { "name": shard_name, "packages": selected, - "all_features": shard_name == "relay", + "all_features": shard_name in {"relay", "relay-v2"}, } ) @@ -626,6 +631,7 @@ def classify( "platform": platform, "platform_hygiene": platform_hygiene, "relay_contracts": "registry-relay" in affected, + "relay_v2_contracts": bool(affected & RELAY_V2_PACKAGES), "evidence_contracts": bool(affected & EVIDENCE_PACKAGES), "project_authoring": "registryctl" in affected, "release_tool": release_tool, diff --git a/.github/scripts/test_ci_changes.py b/.github/scripts/test_ci_changes.py index c12639c50..b148385ca 100644 --- a/.github/scripts/test_ci_changes.py +++ b/.github/scripts/test_ci_changes.py @@ -183,6 +183,7 @@ def test_orphan_platform_crates_and_oid4vci_fuzz_surface_are_absent(self) -> Non self.assertFalse(Path("crates", crate).exists()) self.assertIn("registry-platform-pdp", SHARDS["platform"]) + self.assertIn("registry-platform-sqlite", SHARDS["platform"]) self.assertIn("registry-platform-testing", SHARDS["platform"]) self.assertFalse( Path( @@ -213,6 +214,28 @@ def test_shards_cover_every_workspace_package_once(self) -> None: self.assertCountEqual(assigned, self.workspace.package_names) self.assertEqual(len(assigned), len(set(assigned))) + def test_relay_v2_paths_select_only_the_v2_product_contract(self) -> None: + outputs = classify( + self.workspace, + ("crates/registry-relay-v2/src/compiler.rs",), + ) + self.assertIn("registry-relay-v2", outputs["rust_packages"]) + self.assertIn("registry-relayctl", outputs["rust_packages"]) + self.assertTrue(outputs["relay_v2_contracts"]) + self.assertFalse(outputs["relay_contracts"]) + self.assertNotIn("registryctl", outputs["rust_packages"]) + + def test_relay_v2_product_material_selects_runtime_and_tooling(self) -> None: + outputs = classify( + self.workspace, + ("products/relay-v2/contracts/security-invariants.yaml",), + ) + self.assertEqual( + set(outputs["rust_packages"]), + {"registry-relay-v2", "registry-relayctl"}, + ) + self.assertTrue(outputs["relay_v2_contracts"]) + def test_example_pr_runs_only_affected_rust_shards(self) -> None: outputs = classify( self.workspace, diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ec687de11..46cfd07d7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,7 @@ jobs: platform: ${{ steps.filter.outputs.platform }} platform_hygiene: ${{ steps.filter.outputs.platform_hygiene }} relay_contracts: ${{ steps.filter.outputs.relay_contracts }} + relay_v2_contracts: ${{ steps.filter.outputs.relay_v2_contracts }} evidence_contracts: ${{ steps.filter.outputs.evidence_contracts }} project_authoring: ${{ steps.filter.outputs.project_authoring }} release_tool: ${{ steps.filter.outputs.release_tool }} @@ -274,6 +275,7 @@ jobs: - authcommon_parsers - sdjwt_holder_proof - sdjwt_issuance + - sqlite_statement steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 @@ -533,6 +535,32 @@ jobs: cargo test --locked -p registry-relay --test api_docs openapi_json_can_be_moved_to_public_router_for_local_testing -- --exact + relay-v2-contracts: + name: Relay V2 product contracts + needs: changes + if: needs.changes.outputs.relay_v2_contracts == '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: Relay V2 contract consistency + run: products/relay-v2/scripts/check-contracts.sh + + - name: Relay V2 coequal HTTP journeys + run: products/relay-v2/scripts/test-http.sh + rust-result: name: Rust workspace if: always() @@ -543,6 +571,7 @@ jobs: - rust-tests - evidence-contracts - relay-contracts + - relay-v2-contracts runs-on: ubuntu-24.04 env: RUST_JOB_RESULTS: ${{ toJSON(needs) }} @@ -676,6 +705,9 @@ jobs: - name: Test release workflow structure run: python3 -m unittest release/scripts/test_release_workflow_structure.py + - name: Test maintained Debian 13 image checks + run: python3 -m unittest release/scripts/test_check_debian13_images.py + - name: Test release workflow guard run: python3 -m unittest release/scripts/test_release_workflow_guard.py diff --git a/AGENTS.md b/AGENTS.md index 2a08c1f2e..0a0539bb8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,7 +48,10 @@ dependency runs one way only in production: no Evidence crate depends on | `crates/registry-mint` | Short-lived access tokens for registered clients, and the `mint` binary | | `crates/registry-manifest-*` | Manifest core types and CLI | | `crates/registry-platform-*` | Shared primitives used by the maintained runtimes and tooling | +| `crates/registry-platform-sqlite` | Shared bounded read-only SQLite security boundary used by Relay V2 and Evidence | | `crates/registryctl` | Relay adopter tooling | +| `crates/registry-relay-v2` | Contract-compiled Relay V2 runtime and the `relay` binary; additive beside legacy Relay | +| `crates/registry-relayctl` | Relay V2 adopter tooling and the `relayctl` binary; it does not replace `registryctl` | | `crates/registry-evidence-oid4vci` | Wallet-facing OID4VCI delivery front end for Evidence credentials, and the `evidence-oid4vci` binary | | `crates/registry-language-server` | Editor language server for Relay manifests and Evidence authoring documents, linked into both adopter tools | | `products/` | Product-owned specs, examples, fixtures, docs (not crates) | @@ -56,6 +59,11 @@ dependency runs one way only in production: no Evidence crate depends on | `release/` | Release manifests, schemas, notes, validation and conformance tooling, and the release source-model proof | | `external/` | Notes on inputs that intentionally stay out of this tree (e.g. Crosswalk stays a pinned git dependency) | +Relay V2 is developed additively under `registry-relay-v2` and +`registry-relayctl`. It must not change the behavior or configuration contract +of `registry-relay` or `registryctl`. Its approved contracts, coequal acceptance +projects, and gates live under `products/relay-v2`. + ## Evidence product boundary Evidence is its own minimum-disclosure assertion product, not a Relay mode. diff --git a/Cargo.lock b/Cargo.lock index 93663667c..61d25b400 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5601,6 +5601,7 @@ dependencies = [ "registry-platform-httputil", "registry-platform-oidc", "registry-platform-sdjwt", + "registry-platform-sqlite", "reqwest 0.12.28", "rhai", "rusqlite", @@ -6077,6 +6078,19 @@ dependencies = [ "ulid", ] +[[package]] +name = "registry-platform-sqlite" +version = "0.18.0" +dependencies = [ + "rusqlite", + "rustix", + "serde", + "sha2 0.11.0", + "tempfile", + "thiserror 2.0.18", + "tokio", +] + [[package]] name = "registry-platform-testing" version = "0.18.0" @@ -6182,6 +6196,60 @@ dependencies = [ "zip", ] +[[package]] +name = "registry-relay-v2" +version = "0.18.0" +dependencies = [ + "async-trait", + "axum", + "base64", + "bytes", + "chrono", + "clap", + "futures", + "hex", + "hmac 0.13.0", + "http", + "jsonwebtoken", + "registry-platform-audit", + "registry-platform-buildinfo", + "registry-platform-canonical-json", + "registry-platform-config", + "registry-platform-httpsec", + "registry-platform-httputil", + "registry-platform-oidc", + "registry-platform-sqlite", + "registry-platform-testing", + "reqwest 0.12.28", + "rustix", + "serde", + "serde_json", + "serde_norway", + "sha2 0.11.0", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tower", + "tower-http 0.7.0", + "tracing", + "tracing-subscriber", + "ulid", + "url", + "utoipa", + "zeroize", +] + +[[package]] +name = "registry-relayctl" +version = "0.18.0" +dependencies = [ + "clap", + "registry-platform-buildinfo", + "registry-relay-v2", + "serde", + "serde_json", +] + [[package]] name = "registryctl" version = "0.18.0" diff --git a/Cargo.toml b/Cargo.toml index 51f0b4ff2..54d408c9c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,11 +21,14 @@ members = [ "crates/registry-platform-ops", "crates/registry-platform-pdp", "crates/registry-platform-sdjwt", + "crates/registry-platform-sqlite", "crates/registry-platform-testing", "crates/registry-manifest-core", "crates/registry-manifest-cli", "crates/registry-mint", "crates/registry-relay", + "crates/registry-relay-v2", + "crates/registry-relayctl", "crates/registry-language-server", "crates/registryctl", ] @@ -58,6 +61,8 @@ registry-language-server = { path = "crates/registry-language-server", version = registry-manifest-core = { path = "crates/registry-manifest-core", version = "0.18.0" } registry-mint = { path = "crates/registry-mint", version = "0.18.0" } registry-relay = { path = "crates/registry-relay", version = "0.18.0" } +registry-relay-v2 = { path = "crates/registry-relay-v2", version = "0.18.0" } +registry-relayctl = { path = "crates/registry-relayctl", version = "0.18.0" } registry-platform-audit = { path = "crates/registry-platform-audit", version = "0.18.0" } registry-platform-authcommon = { path = "crates/registry-platform-authcommon", version = "0.18.0" } registry-platform-buildinfo = { path = "crates/registry-platform-buildinfo", version = "0.18.0" } @@ -70,6 +75,7 @@ registry-platform-oidc = { path = "crates/registry-platform-oidc", version = "0. registry-platform-ops = { path = "crates/registry-platform-ops", version = "0.18.0" } registry-platform-pdp = { path = "crates/registry-platform-pdp", version = "0.18.0" } registry-platform-sdjwt = { path = "crates/registry-platform-sdjwt", version = "0.18.0" } +registry-platform-sqlite = { path = "crates/registry-platform-sqlite", version = "0.18.0" } registry-platform-testing = { path = "crates/registry-platform-testing", version = "0.18.0" } crosswalk-core = { git = "https://github.com/PublicSchema/crosswalk", rev = "1d44ec735fdc8a7c719264b339574371e8330337", version = "0.2.0" } diff --git a/crates/registry-evidence/Cargo.toml b/crates/registry-evidence/Cargo.toml index 0a6332392..6354160e5 100644 --- a/crates/registry-evidence/Cargo.toml +++ b/crates/registry-evidence/Cargo.toml @@ -39,6 +39,7 @@ registry-platform-httpsec.workspace = true registry-platform-httputil.workspace = true registry-platform-oidc.workspace = true registry-platform-sdjwt.workspace = true +registry-platform-sqlite = { workspace = true, features = ["fixture"] } reqwest.workspace = true rand_core.workspace = true rhai.workspace = true diff --git a/crates/registry-evidence/src/bundle.rs b/crates/registry-evidence/src/bundle.rs index a92481703..5e5a8e7fc 100644 --- a/crates/registry-evidence/src/bundle.rs +++ b/crates/registry-evidence/src/bundle.rs @@ -11,6 +11,7 @@ use jsonschema::{Draft, JSONSchema}; use registry_platform_crypto::{ canonicalize_json, PublicJwk, SigningAlgorithm as ProviderSigningAlgorithm, }; +use registry_platform_sqlite::{CapturedSnapshot, ErrorKind as SqliteErrorKind}; use rhai::{Engine, AST}; use serde::de::{self, MapAccess, Visitor}; use serde::{Deserialize, Deserializer}; @@ -46,6 +47,7 @@ const MAX_CA_BUNDLE_BYTES: u64 = 1024 * 1024; /// Bytes folded into an extract's digest per read. An extract is sized by the /// register it holds rather than by a byte cap, so it is digested in chunks of /// this size and never held whole. +#[cfg(test)] const EXTRACT_DIGEST_CHUNK_BYTES: usize = 64 * 1024; const ALLOWED_DIRECTORIES: [&str; 7] = [ "adapters", @@ -311,9 +313,7 @@ pub struct RuntimeDocument { /// identity both were taken over. #[derive(Debug, Clone, Eq, PartialEq)] pub struct SourceExtract { - path: PathBuf, - digest: String, - identity: FileIdentity, + captured: CapturedSnapshot, } /// The identity a capture was taken over, kept so a later opener can prove it @@ -323,25 +323,32 @@ pub struct SourceExtract { /// compares, so the two cannot drift apart: whatever identity a read brackets /// itself with is the identity a reopen is held to. #[derive(Debug, Clone)] +#[cfg(test)] struct FileIdentity(Metadata); +#[cfg(test)] impl PartialEq for FileIdentity { fn eq(&self, other: &Self) -> bool { same_file(&self.0, &other.0) } } +#[cfg(test)] impl Eq for FileIdentity {} impl SourceExtract { + pub(crate) fn captured_snapshot(&self) -> CapturedSnapshot { + self.captured.clone() + } + /// The validated file the statement executor opens. pub fn path(&self) -> &Path { - &self.path + self.captured.path() } /// `sha256:` and lowercase hex over the extract's bytes, as they were read. pub fn digest(&self) -> &str { - &self.digest + self.captured.digest() } /// Prove the bound path still names the file this validated and digested. @@ -359,18 +366,16 @@ impl SourceExtract { /// window still passes, and anyone who can write the containing directory /// can do worse than this. The case it settles is the one that happens. pub fn confirm_still_bound(&self) -> Result<(), BundleError> { - let current = fs::symlink_metadata(&self.path).map_err(|_| { - invalid_artifact("the source extract the runtime file names is unavailable") - })?; - if current.file_type().is_symlink() - || !current.is_file() - || FileIdentity(current) != self.identity - { - return Err(not_immutable( - "the source extract was replaced between digesting it and opening it", - )); - } - Ok(()) + self.captured + .confirm_still_bound() + .map_err(|error| match error.kind() { + SqliteErrorKind::DatabaseUnavailable => { + invalid_artifact("the source extract the runtime file names is unavailable") + } + _ => not_immutable( + "the source extract was replaced between digesting it and opening it", + ), + }) } } @@ -809,37 +814,35 @@ fn source_extract_artifact(profile: &str) -> String { /// know whether the file is absent, indirect, of the wrong kind, or writable, /// and those are four different pieces of work. fn capture_source_extract(path: &Path) -> Result { - let unavailable = invalid_artifact("the source extract the runtime file names is unavailable"); - let metadata = fs::symlink_metadata(path).map_err(|_| unavailable.clone())?; - if metadata.file_type().is_symlink() { - return Err(invalid_artifact( - "the source extract the runtime file names is a symbolic link", - )); - } - if !metadata.is_file() { - return Err(invalid_artifact( - "the source extract the runtime file names is not a regular file", - )); + let captured = CapturedSnapshot::capture(path).map_err(map_extract_capture_error)?; + Ok(SourceExtract { captured }) +} + +fn map_extract_capture_error(error: registry_platform_sqlite::SqliteError) -> BundleError { + match error.kind() { + SqliteErrorKind::DatabaseUnavailable => { + invalid_artifact("the source extract the runtime file names is unavailable") + } + SqliteErrorKind::DatabaseSymlink => { + invalid_artifact("the source extract the runtime file names is a symbolic link") + } + SqliteErrorKind::DatabaseNotFile => { + invalid_artifact("the source extract the runtime file names is not a regular file") + } + SqliteErrorKind::DatabaseWritable => { + invalid_artifact("the source extract the runtime file names is writable") + } + SqliteErrorKind::UncheckpointedSidecar => invalid_artifact( + "the source extract the runtime file names has an uncheckpointed sidecar", + ), + SqliteErrorKind::DatabaseReplaced => { + not_immutable("the source extract was replaced between naming it and opening it") + } + SqliteErrorKind::DatabaseChanged => { + not_immutable("the source extract changed while it was being read") + } + _ => invalid_artifact("the source extract the runtime file names is unavailable"), } - let filesystem_read_only = filesystem_is_read_only(path).map_err(|_| unavailable)?; - // Not hygiene. The statement executor opens this file `mode=ro` with - // `immutable=1`, which promises SQLite that no other connection can change - // it. A file that is still writable makes that promise false, and an - // immutable connection over a file that changes is undefined behaviour - // rather than a stale read. Dropping this check would move that undefined - // behaviour into every assertion the deployment answers. - validate_read_only( - &metadata, - filesystem_read_only, - "the source extract the runtime file names is writable", - )?; - refuse_uncheckpointed_sidecars(path)?; - let (digest, identity) = digest_stable_file(path, &metadata, filesystem_read_only)?; - Ok(SourceExtract { - path: path.to_path_buf(), - digest, - identity, - }) } /// The files SQLite writes beside a database and reads back to complete it. @@ -847,38 +850,9 @@ fn capture_source_extract(path: &Path) -> Result { /// A `-shm` is deliberately absent. It is shared memory rather than content, /// and one can survive a clean checkpoint and close, so its presence says /// nothing about whether the snapshot is whole. +#[cfg(test)] const EXTRACT_SIDECAR_SUFFIXES: [&str; 2] = ["-wal", "-journal"]; -/// Refuse an extract published with the sidecar that completes it. -/// -/// `immutable=1` tells SQLite to skip change detection, and skipping change -/// detection also skips these files. A `-wal` holding committed frames is read -/// straight past, so the deployment answers from the last checkpoint while -/// every other reader of the same file sees newer rows. A `-journal` left by a -/// writer that died mid-transaction is worse: an ordinary read-only opener -/// refuses such a file because it cannot perform the rollback, while an -/// immutable opener reads the rows that transaction never committed as though -/// they were authoritative. -/// -/// An extract is a published snapshot, so a sidecar is a publishing mistake -/// rather than a state to interpret, and this makes it a startup refusal -/// instead of a silent one. It detects that mistake rather than preventing it: -/// a sidecar appearing after this check belongs to the deployment's own -/// guarantee that nothing else changes the mounted file, and a publisher who -/// copies only the main file out of a live database leaves no sidecar to find. -fn refuse_uncheckpointed_sidecars(path: &Path) -> Result<(), BundleError> { - for suffix in EXTRACT_SIDECAR_SUFFIXES { - let mut sidecar = path.to_path_buf().into_os_string(); - sidecar.push(suffix); - if fs::symlink_metadata(PathBuf::from(sidecar)).is_ok() { - return Err(invalid_artifact( - "the source extract the runtime file names has an uncheckpointed sidecar", - )); - } - } - Ok(()) -} - /// Digest one file without holding it in memory. /// /// [`read_stable_file`]'s identity discipline over a file too large to @@ -886,6 +860,7 @@ fn refuse_uncheckpointed_sidecars(path: &Path) -> Result<(), BundleError> { /// same before-and-after identity checks bracket the read, so the file /// identity that was validated is the file identity that was hashed. There is /// no byte cap, because an extract is a register rather than an artifact. +#[cfg(test)] fn digest_stable_file( path: &Path, scanned: &Metadata, diff --git a/crates/registry-evidence/src/source.rs b/crates/registry-evidence/src/source.rs index 19478e7f7..54a96026d 100644 --- a/crates/registry-evidence/src/source.rs +++ b/crates/registry-evidence/src/source.rs @@ -274,15 +274,6 @@ pub enum StatementExtract<'a> { Fixture(&'a Path), } -impl<'a> StatementExtract<'a> { - fn path(self) -> &'a Path { - match self { - Self::Bound(extract) => extract.path(), - Self::Fixture(path) => path, - } - } -} - /// Bind one source to what a statement transport needs from outside its own /// configuration, so every caller assembles it the same way. /// @@ -1067,8 +1058,17 @@ impl StatementTransport { // settled here instead of going unchecked. let extract = match inputs.extract { Some(bound) => { - let opened = SqliteExtractSource::open(source, inputs.statement_sql, bound.path()) - .map_err(map_statement_error)?; + let opened = match bound { + StatementExtract::Bound(extract) => SqliteExtractSource::open_captured( + source, + inputs.statement_sql, + extract.captured_snapshot(), + ), + StatementExtract::Fixture(path) => { + SqliteExtractSource::open(source, inputs.statement_sql, path) + } + } + .map_err(map_statement_error)?; // Digesting the file and opening it are not the same moment: // the bundle, the kernel, and the audit log are read in // between. A publisher refreshing the bound path inside that diff --git a/crates/registry-evidence/src/source_sqlite.rs b/crates/registry-evidence/src/source_sqlite.rs index 47726d1c3..5c94c99db 100644 --- a/crates/registry-evidence/src/source_sqlite.rs +++ b/crates/registry-evidence/src/source_sqlite.rs @@ -1,9 +1,9 @@ //! Bounded execution of one reviewed SQL statement against a read-only extract. //! //! The transport holds one reviewed statement and one extract file. Review of -//! the statement is the disclosure control: the authorizer below is a safety -//! boundary that refuses whole categories of SQL, not a per-table or -//! per-column declaration of what may be read. +//! the statement is the disclosure control. `registry-platform-sqlite` supplies +//! the safety boundary that refuses whole categories of SQL; it is not a +//! per-table or per-column declaration of what may be read. //! //! Every failure this module reports names the bundle artifact it came from //! and a cause drawn from [`cause`], so an adopter is told which file to open @@ -12,24 +12,22 @@ //! this crate carries data. use std::collections::BTreeMap; -use std::ffi::c_int; use std::path::Path; -use std::sync::atomic::{AtomicU8, Ordering}; -use std::sync::{Arc, Mutex}; -use std::time::{Duration, Instant}; +use std::sync::Arc; +use std::time::Duration; use chrono::{DateTime, SecondsFormat, SubsecRound as _, Utc}; -use rusqlite::hooks::{AuthAction, AuthContext, Authorization}; -use rusqlite::limits::Limit; -use rusqlite::types::ValueRef; -use rusqlite::{Connection, ErrorCode, OpenFlags, Row, Statement}; +use registry_platform_sqlite::{ + CapturedSnapshot, ColumnContract, ColumnType, DatabaseProfile, ErrorKind as PlatformErrorKind, + ParameterContract, ReadOnlyStatement, StatementContract, StatementLimits, + Value as PlatformValue, +}; use serde_json::{Map as JsonMap, Number as JsonNumber, Value as JsonValue}; use thiserror::Error; -use tokio::sync::Semaphore; use crate::bundle::ArtifactFault; use crate::config::{ - SchemaFault, SourceConfig, SqliteColumn, SqliteColumnType, SqliteRequest, TextLocation, + SchemaFault, SourceConfig, SqliteColumnType, SqliteRequest, TextLocation, RESERVED_SQL_PARAMETER, }; use crate::model::SelectorValue; @@ -37,18 +35,13 @@ use crate::model::SelectorValue; /// The reserved metadata table every extract carries. pub const EXTRACT_METADATA_TABLE: &str = "evidence_extract"; -/// Virtual machine steps between progress callbacks. -/// -/// The callback is the only cancellation this transport has, so the interval -/// sets the resolution of both bounds. SQLite runs on the order of a hundred -/// million steps a second here, so a thousand steps is about ten microseconds: -/// fine enough that the smallest legal `timeoutMilliseconds` of 1 is honoured -/// to roughly a percent, and coarse enough that the callback itself is a -/// rounding error against the statement it guards. -const PROGRESS_STEP_INTERVAL: u64 = 1_000; - /// The largest metadata field an extract may declare. const MAXIMUM_METADATA_FIELD_BYTES: usize = 1_024; +/// Worst-case JSON accounting for three maximum-size metadata strings plus +/// their fixed keys and row/collection framing. A one-byte control character +/// can require six JSON bytes, while the frozen metadata contract bounds the +/// original UTF-8 field bytes rather than their serialized representation. +const MAXIMUM_METADATA_RESPONSE_BYTES: usize = 20 * 1_024; /// The engine's own ceiling on one value and one assembled record. /// @@ -68,40 +61,9 @@ const MAXIMUM_METADATA_FIELD_BYTES: usize = 1_024; /// `maximumCellBytes`, which may legally be as low as 1, would refuse ordinary /// statements. The value admits the largest result the configuration bounds /// allow, 64 columns of 65,536 bytes, with room to spare. +#[cfg(test)] const MAXIMUM_ENGINE_VALUE_BYTES: i32 = 8 * 1_024 * 1_024; -/// SQL functions the authorizer refuses by name. -/// -/// The authorizer sees a function's name but never its arguments, so the whole -/// clock family is refused rather than only its `'now'` forms. Evidence binds -/// the runtime's evaluation instant to [`RESERVED_SQL_PARAMETER`] instead, so a -/// statement that needs the current time has a deterministic way to ask for it. -/// -/// This is a denylist, not an allowlist: an allowlist would refuse ordinary -/// deterministic SQL such as `substr`, `printf` and `coalesce` and would grow a -/// support burden for every adopter. The set below is closed against the -/// vendored SQLite amalgamation, which is pinned by `Cargo.lock` and changes -/// only when the dependency is deliberately raised. -const DENIED_FUNCTIONS: &[&str] = &[ - "changes", - "current_date", - "current_time", - "current_timestamp", - "date", - "datetime", - "julianday", - "last_insert_rowid", - "load_extension", - "random", - "randomblob", - "sqlite_offset", - "strftime", - "time", - "timediff", - "total_changes", - "unixepoch", -]; - /// The closed cause vocabulary a statement source reports. pub mod cause { pub const MULTIPLE_STATEMENTS: &str = "the artifact holds more than one statement"; @@ -173,30 +135,6 @@ fn extract_fault(subject: &str, cause: &'static str) -> SqliteSourceError { SqliteSourceError::Extract(ArtifactFault::new(subject, SchemaFault::because(cause))) } -/// Where a byte offset into some text falls, as a one-based line and column. -/// -/// A column counts characters rather than bytes, which is what the -/// configuration decoder's own locations count, so one convention holds across -/// every deployment diagnostic and a position is the one an adopter's editor -/// shows them. An offset at or past the end of the text is the position just -/// after the last character. -fn text_location(text: &str, offset: usize) -> TextLocation { - let mut line = 1; - let mut column = 1; - for (index, character) in text.char_indices() { - if index >= offset { - break; - } - if character == '\n' { - line += 1; - column = 1; - } else { - column += 1; - } - } - TextLocation { line, column } -} - /// The publisher's own statement about one extract file. /// /// The publication instant is read from the extract's reserved metadata table, @@ -301,23 +239,9 @@ pub fn check_statement_offline( ) -> Result<(), SqliteSourceError> { let (request, _, _) = statement_source(source)?; let artifact = request.statement.as_str(); - let connection = Connection::open_in_memory() - .map_err(|_| statement_fault(artifact, cause::EXECUTION_FAILED))?; - install_authorizer(&connection) - .map_err(|_| statement_fault(artifact, cause::EXECUTION_FAILED))?; - // The prepared statement is dropped in `map` so that it cannot outlive the - // connection it borrows. - match connection.prepare(statement_sql).map(|_| ()) { - Ok(()) => Ok(()), - Err(error) => { - let fault = classify_prepare(&error); - match fault.cause { - // Only the extract can settle these, and the extract is not here. - cause::UNKNOWN_TABLE | cause::UNKNOWN_COLUMN => Ok(()), - _ => Err(fault.statement_fault(artifact, statement_sql)), - } - } - } + let contract = platform_contract(request, statement_sql)?; + registry_platform_sqlite::check_statement_offline(&contract) + .map_err(|error| map_platform_error(error, artifact, "extract")) } fn statement_source( @@ -334,38 +258,11 @@ fn statement_source( } } -/// One statement parameter, at the index SQLite assigned it. -#[derive(Debug, Clone)] -struct BoundParameter { - index: usize, - name: String, -} - -/// A value on its way into a statement. Booleans travel as SQLite integers, -/// which is how SQLite itself stores them. -#[derive(Debug, Clone)] -enum BoundValue { - Text(String), - Integer(i64), -} - /// The reviewed statement and the bounds its result is read under. #[derive(Debug)] struct StatementPlan { artifact: String, - sql: String, - columns: Vec, - parameters: Vec, - maximum_rows: u64, - maximum_cell_bytes: usize, - /// The same bound the caller measures the serialized response against, held - /// here so collection can refuse before the whole result exists. The bounds - /// above it are per row and per cell, and nothing bounds their product, so - /// at the schema maxima a result can reach a gibibyte before the caller ever - /// sees it. - maximum_response_bytes: usize, - maximum_statement_steps: u64, - timeout: Duration, + parameters: Vec, maximum_extract_age_seconds: u64, extract_profile: String, } @@ -387,57 +284,57 @@ pub struct SqliteExtractSource { plan: Arc, metadata: ExtractMetadata, extract: JsonValue, - /// The pool and its permits are shared with the blocking task that borrows - /// from them, because a caller that stops awaiting must not be able to carry - /// either away. See [`SqliteExtractSource::execute`]. - connections: Arc>>, - concurrency: Arc, + /// The platform boundary owns the connection pool, admission permits, and + /// cancellation recovery. + statement: ReadOnlyStatement, } impl SqliteExtractSource { /// Open an extract and run the strong check against it. /// - /// `extract_path` must name a file the process cannot write and no other - /// process will change while this source lives. The caller owns that - /// precondition, and it is what makes `immutable=1` below sound. + /// `extract_path` must name a regular, unwritable, sidecar-free file. The + /// platform capture enforces that precondition before using `immutable=1`. pub fn open( source: &SourceConfig, statement_sql: &str, extract_path: &Path, + ) -> Result { + let (_, _, extract_profile) = statement_source(source)?; + let captured = CapturedSnapshot::capture(extract_path) + .map_err(|_| extract_fault(extract_profile, cause::EXTRACT_UNAVAILABLE))?; + Self::open_captured(source, statement_sql, captured) + } + + /// Open the exact snapshot already validated and digest-bound by the + /// runtime document. Re-capturing by path here could accept a replacement + /// under the old runtime revision. + pub(crate) fn open_captured( + source: &SourceConfig, + statement_sql: &str, + captured: CapturedSnapshot, ) -> Result { let (request, maximum_extract_age_seconds, extract_profile) = statement_source(source)?; let artifact = request.statement.as_str(); - let uri = extract_uri(extract_path) - .ok_or_else(|| extract_fault(extract_profile, cause::EXTRACT_UNAVAILABLE))?; - - let permits = usize::from(request.concurrency_limit); - let mut connections = Vec::with_capacity(permits); - for _ in 0..permits { - connections.push(open_extract(&uri, extract_profile)?); - } - let first = connections.first().ok_or(SqliteSourceError::InvalidPlan)?; - let timeout = Duration::from_millis(request.timeout_milliseconds); + let profile = DatabaseProfile::Snapshot(captured); let metadata = read_extract_metadata( - first, + &profile, extract_profile, request.maximum_statement_steps, timeout, )?; - let parameters = verify_statement(first, request, statement_sql)?; + let statement = + ReadOnlyStatement::open(profile, platform_contract(request, statement_sql)?) + .map_err(|error| map_platform_error(error, artifact, extract_profile))?; + let parameters = request + .parameter_bindings + .keys() + .map(str::to_owned) + .collect(); let plan = StatementPlan { artifact: artifact.to_owned(), - sql: statement_sql.to_owned(), - columns: request.columns.clone(), parameters, - maximum_rows: request.maximum_rows, - maximum_cell_bytes: usize::try_from(request.maximum_cell_bytes) - .map_err(|_| SqliteSourceError::InvalidPlan)?, - maximum_response_bytes: usize::try_from(request.maximum_response_bytes) - .map_err(|_| SqliteSourceError::InvalidPlan)?, - maximum_statement_steps: request.maximum_statement_steps, - timeout, maximum_extract_age_seconds, extract_profile: extract_profile.to_owned(), }; @@ -445,8 +342,7 @@ impl SqliteExtractSource { plan: Arc::new(plan), extract: metadata.as_json(), metadata, - connections: Arc::new(Mutex::new(connections)), - concurrency: Arc::new(Semaphore::new(permits)), + statement, }) } @@ -479,10 +375,10 @@ impl SqliteExtractSource { /// /// The result is `{"rows": [...], "extract": {...}}`. Applying the /// acquisition projection belongs to the caller, which does it for every - /// transport, and so does the authoritative response size check, which - /// measures the serialized bytes. Collection here reads the same bound - /// against the text payload alone, which is only ever shorter, so it can - /// refuse sooner but never refuse a result the caller would accept. + /// transport, and so does the authoritative response size check. Collection + /// here conservatively charges the serialized collection, row, column-name, + /// and scalar-value structure against the same bound so the intermediate + /// result is bounded before the caller projects it. /// /// A caller may stop awaiting this at any point: the acquisition deadline /// above it expires, or a client disconnects and the handler future is @@ -498,50 +394,17 @@ impl SqliteExtractSource { evaluation_instant: DateTime, ) -> Result { let bindings = self.bind_values(parameters, evaluation_instant)?; - // One absolute deadline covers every wait this source owns. In - // particular, admission cannot consume this window and then hand a - // fresh one to SQLite, and a task waiting for a blocking worker cannot - // outlive it either. - let deadline = Instant::now() + self.plan.timeout; - let asynchronous_deadline = tokio::time::Instant::from_std(deadline); - // An owned permit so the blocking task can hold it; a borrowed one would - // be tied to this future, which is the lifetime being escaped. - let permit = tokio::time::timeout_at( - asynchronous_deadline, - Arc::clone(&self.concurrency).acquire_owned(), - ) - .await - .map_err(|_| SqliteSourceError::Timeout)? - .map_err(|_| SqliteSourceError::Concurrency)?; - let connection = self.take_connection()?; - let plan = Arc::clone(&self.plan); - let connections = Arc::clone(&self.connections); - - let execution = tokio::task::spawn_blocking(move || { - let outcome = run_statement(&connection, &plan, &bindings, deadline); - return_connection(&connections, connection); - drop(permit); - outcome - }); - // Timing out this await detaches rather than cancels the blocking task. - // The task still owns the connection and permit and returns both from - // inside its closure, preserving the pool after a queue or execution - // timeout just as it does after caller cancellation. - let outcome = tokio::time::timeout_at(asynchronous_deadline, execution) + let rows = self + .statement + .execute(&bindings) .await - .map_err(|_| SqliteSourceError::Timeout)? - .map_err(|_| SqliteSourceError::Unavailable)?; - - let rows = match outcome { - Ok(rows) => rows, - // Admission, worker scheduling and execution share one time limit - // and therefore one operator category. Normalizing the progress - // handler's execution-time signal avoids a race where the same - // deadline could be reported differently depending on whether the - // blocking task or Tokio's timer was polled first. - Err(cause::TIME_BUDGET_EXCEEDED) => return Err(SqliteSourceError::Timeout), - Err(cause) => return Err(self.plan.fault(cause)), - }; + .map_err(|error| { + map_platform_error(error, &self.plan.artifact, &self.plan.extract_profile) + })? + .rows + .into_iter() + .map(platform_row_json) + .collect(); let mut result = JsonMap::new(); result.insert("rows".to_owned(), JsonValue::Array(rows)); result.insert("extract".to_owned(), self.extract.clone()); @@ -552,7 +415,7 @@ impl SqliteExtractSource { &self, parameters: &BTreeMap, evaluation_instant: DateTime, - ) -> Result, SqliteSourceError> { + ) -> Result, SqliteSourceError> { // Fixed-width RFC 3339 UTC, so a statement that compares the instant // against stored text orders lexically the way it orders in time. Whole // seconds, because this is the same rendering the assertion carries: a @@ -561,49 +424,124 @@ impl SqliteExtractSource { // it. The runtime truncates the instant where it reads the clock, so in // production this only chooses how an already-whole second is written. let instant = evaluation_instant.to_rfc3339_opts(SecondsFormat::Secs, true); - let mut bound = Vec::with_capacity(self.plan.parameters.len()); + let mut bound = BTreeMap::new(); for parameter in &self.plan.parameters { - let value = if parameter.name == RESERVED_SQL_PARAMETER { - BoundValue::Text(instant.clone()) - } else { - match parameters.get(¶meter.name) { - Some(SelectorValue::String(text)) => BoundValue::Text(text.clone()), - Some(SelectorValue::Integer(number)) => BoundValue::Integer(*number), - Some(SelectorValue::Boolean(flag)) => BoundValue::Integer(i64::from(*flag)), - None => return Err(self.plan.fault(cause::MISSING_PARAMETER)), - } + let value = match parameters.get(parameter) { + Some(SelectorValue::String(text)) => PlatformValue::String(text.clone()), + Some(SelectorValue::Integer(number)) => PlatformValue::Integer(*number), + Some(SelectorValue::Boolean(flag)) => PlatformValue::Boolean(*flag), + None => return Err(self.plan.fault(cause::MISSING_PARAMETER)), }; - bound.push((parameter.index, value)); + bound.insert(parameter.clone(), value); } + bound.insert( + RESERVED_SQL_PARAMETER.to_owned(), + PlatformValue::String(instant), + ); Ok(bound) } +} - /// Take the connection this request's permit stands for. - /// - /// A poisoned lock is recovered rather than refused, which is the same - /// choice [`return_connection`] makes: the only operations this pool has are - /// a push and a pop, so a panic elsewhere cannot have left it half-written, - /// and refusing to touch it would strand every connection in it. - fn take_connection(&self) -> Result { - self.connections - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .pop() - .ok_or(SqliteSourceError::Unavailable) +fn platform_contract( + request: &SqliteRequest, + statement_sql: &str, +) -> Result { + let columns = request + .columns + .iter() + .map(|column| ColumnContract { + name: column.name.clone(), + value_type: match column.value_type { + SqliteColumnType::String => ColumnType::String, + SqliteColumnType::Integer => ColumnType::Integer, + SqliteColumnType::Number => ColumnType::Number, + SqliteColumnType::Boolean => ColumnType::Boolean, + }, + }) + .collect(); + let mut parameters: Vec<_> = request + .parameter_bindings + .keys() + .map(|name| ParameterContract { + name: name.to_owned(), + required: true, + }) + .collect(); + parameters.push(ParameterContract { + name: RESERVED_SQL_PARAMETER.to_owned(), + required: false, + }); + Ok(StatementContract { + sql: statement_sql.to_owned(), + columns, + parameters, + limits: StatementLimits { + maximum_rows: request.maximum_rows, + maximum_cell_bytes: usize::try_from(request.maximum_cell_bytes) + .map_err(|_| SqliteSourceError::InvalidPlan)?, + maximum_response_bytes: usize::try_from(request.maximum_response_bytes) + .map_err(|_| SqliteSourceError::InvalidPlan)?, + maximum_statement_steps: request.maximum_statement_steps, + timeout: Duration::from_millis(request.timeout_milliseconds), + concurrency: usize::from(request.concurrency_limit), + }, + schema: None, + }) +} + +fn map_platform_error( + error: registry_platform_sqlite::SqliteError, + artifact: &str, + extract_profile: &str, +) -> SqliteSourceError { + let cause = match error.kind() { + PlatformErrorKind::InvalidPlan => return SqliteSourceError::InvalidPlan, + PlatformErrorKind::Concurrency => return SqliteSourceError::Concurrency, + PlatformErrorKind::Timeout | PlatformErrorKind::TimeBudgetExceeded => { + return SqliteSourceError::Timeout; + } + PlatformErrorKind::WorkerUnavailable => return SqliteSourceError::Unavailable, + PlatformErrorKind::DatabaseUnavailable + | PlatformErrorKind::DatabaseReplaced + | PlatformErrorKind::DatabaseWritable + | PlatformErrorKind::DatabaseSymlink + | PlatformErrorKind::DatabaseNotFile + | PlatformErrorKind::DatabaseChanged + | PlatformErrorKind::UncheckpointedSidecar => { + return extract_fault(extract_profile, cause::EXTRACT_UNAVAILABLE); + } + _ => error.cause(), + }; + match error.location() { + Some(location) => SqliteSourceError::Statement(ArtifactFault::at( + artifact, + SchemaFault::because(cause), + TextLocation { + line: location.line, + column: location.column, + }, + )), + None => statement_fault(artifact, cause), } } -/// Put a connection back where the next admitted request will find it. -/// -/// This is a free function rather than a method because it runs inside the -/// blocking task, which holds no reference to the source. A poisoned lock is -/// recovered here rather than propagated, because dropping the connection is -/// exactly the pool drain this path exists to prevent. -fn return_connection(connections: &Mutex>, connection: Connection) { - connections - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .push(connection); +fn platform_row_json(row: BTreeMap) -> JsonValue { + JsonValue::Object( + row.into_iter() + .map(|(name, value)| { + let value = match value { + PlatformValue::Null => JsonValue::Null, + PlatformValue::String(value) => JsonValue::String(value), + PlatformValue::Integer(value) => JsonValue::from(value), + PlatformValue::Number(value) => { + JsonNumber::from_f64(value).map_or(JsonValue::Null, JsonValue::Number) + } + PlatformValue::Boolean(value) => JsonValue::Bool(value), + }; + (name, value) + }) + .collect(), + ) } /// Build an extract file from a reviewed text seed. @@ -613,28 +551,24 @@ fn return_connection(connections: &Mutex>, connection: Connectio /// every other bundle artifact, and it keeps table and column names legible to /// the checks that read this tree, none of which an opaque binary would be. /// -/// The connection opened here has no authorizer, and the contrast with -/// [`open_extract`] is the point. A seed is DDL and `INSERT`, which [`authorize`] -/// denies by design, so the world cannot be built through the posture that -/// reads it. This connection is closed before the extract is opened again, -/// read-only and immutable, for the reviewed statement to run against, so -/// building a fixture world and reading one are two connections apart and -/// neither can be mistaken for the other. +/// The fixture connection can execute DDL and `INSERT`; the production reader +/// cannot. This connection is closed before the extract is opened again, +/// read-only and immutable, through `registry-platform-sqlite`, so building a +/// fixture world and reading one are two connections apart and neither can be +/// mistaken for the other. /// /// The finished file is made unwritable, because `immutable=1` on the reading /// connection is sound only against a file nothing will change. pub fn materialize_seed_extract(target: &Path, seed_sql: &str) -> Result<(), SqliteSourceError> { - use std::os::unix::fs::PermissionsExt as _; - let subject = extract_subject(target); - let unavailable = || extract_fault(&subject, cause::EXTRACT_UNAVAILABLE); - let connection = Connection::open(target).map_err(|_| unavailable())?; - connection - .execute_batch(seed_sql) - .map_err(|_| extract_fault(&subject, cause::INVALID_SQL))?; - connection.close().map_err(|_| unavailable())?; - std::fs::set_permissions(target, std::fs::Permissions::from_mode(0o444)) - .map_err(|_| unavailable()) + registry_platform_sqlite::materialize_fixture(target, seed_sql).map_err(|error| { + let cause = if error.kind() == PlatformErrorKind::InvalidSql { + cause::INVALID_SQL + } else { + cause::EXTRACT_UNAVAILABLE + }; + extract_fault(&subject, cause) + }) } /// A fixture-only extract name used internally while its seed is materialized. @@ -647,119 +581,6 @@ fn extract_subject(extract_path: &Path) -> String { ) } -/// The extract as a SQLite URI. -/// -/// `mode=ro` refuses the write path outright. `immutable=1` tells SQLite the -/// file will not change while it is open, which lets it skip locking and -/// journal replay. That flag is sound only because the caller has already -/// proven the extract is read-only and stable for the life of this source; it -/// is a correctness precondition, not a tuning knob, and removing the proof -/// would make the flag unsafe rather than merely slower. -fn extract_uri(extract_path: &Path) -> Option { - let text = extract_path.to_str()?; - let mut uri = String::from("file:"); - for byte in text.bytes() { - match byte { - b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' => { - uri.push(char::from(byte)); - } - other => uri.push_str(&format!("%{other:02X}")), - } - } - uri.push_str("?mode=ro&immutable=1"); - Some(uri) -} - -fn open_extract(uri: &str, subject: &str) -> Result { - let flags = OpenFlags::SQLITE_OPEN_READ_ONLY - | OpenFlags::SQLITE_OPEN_URI - | OpenFlags::SQLITE_OPEN_NO_MUTEX; - let connection = Connection::open_with_flags(uri, flags) - .map_err(|_| extract_fault(subject, cause::EXTRACT_UNAVAILABLE))?; - connection - .set_limit(Limit::SQLITE_LIMIT_LENGTH, MAXIMUM_ENGINE_VALUE_BYTES) - .map_err(|_| extract_fault(subject, cause::EXTRACT_UNAVAILABLE))?; - install_authorizer(&connection) - .map_err(|_| extract_fault(subject, cause::EXTRACT_UNAVAILABLE))?; - Ok(connection) -} - -/// Refuse everything a reviewed read-only statement has no business doing. -/// -/// This is a safety boundary, not a disclosure declaration. It says nothing -/// about which tables or columns a statement may read, because review of the -/// statement is what decides that. It says only that the statement may not -/// write, may not reach another database, may not touch a pragma, may not load -/// an extension, and may not read a clock or a random source. -/// -/// WARNING: `AuthAction` is `#[non_exhaustive]`, so this match cannot be -/// written without a wildcard arm, and the compiler will not report a variant -/// added by a future `rusqlite`. The wildcard therefore DENIES. A `rusqlite` -/// upgrade that introduces an action will refuse statements that use it rather -/// than allow them, which is the right way round: the failure is visible and -/// recoverable, and the alternative would be a silent widening of this -/// boundary. Whoever raises the `rusqlite` version should revisit this match. -/// The stable toolchain has no lint that would catch the omission -/// (`non_exhaustive_omitted_patterns` remains unstable, rust-lang/rust#89554). -fn authorize(action: &AuthAction<'_>) -> Authorization { - match action { - // Reading is what a statement source is for. Which rows and columns it - // may read is settled by review, not here. - AuthAction::Read { .. } | AuthAction::Select | AuthAction::Recursive => { - Authorization::Allow - } - AuthAction::Function { function_name, .. } => { - if DENIED_FUNCTIONS - .iter() - .any(|denied| function_name.eq_ignore_ascii_case(denied)) - { - Authorization::Deny - } else { - Authorization::Allow - } - } - // Every mutating action. - AuthAction::Insert { .. } - | AuthAction::Update { .. } - | AuthAction::Delete { .. } - | AuthAction::CreateIndex { .. } - | AuthAction::CreateTable { .. } - | AuthAction::CreateTempIndex { .. } - | AuthAction::CreateTempTable { .. } - | AuthAction::CreateTempTrigger { .. } - | AuthAction::CreateTempView { .. } - | AuthAction::CreateTrigger { .. } - | AuthAction::CreateView { .. } - | AuthAction::CreateVtable { .. } - | AuthAction::DropIndex { .. } - | AuthAction::DropTable { .. } - | AuthAction::DropTempIndex { .. } - | AuthAction::DropTempTable { .. } - | AuthAction::DropTempTrigger { .. } - | AuthAction::DropTempView { .. } - | AuthAction::DropTrigger { .. } - | AuthAction::DropView { .. } - | AuthAction::DropVtable { .. } - | AuthAction::AlterTable { .. } - | AuthAction::Reindex { .. } - | AuthAction::Analyze { .. } - // Another database file, and the pragma surface that can reach one. - | AuthAction::Attach { .. } - | AuthAction::Detach { .. } - | AuthAction::Pragma { .. } - // Transaction control has no place inside one reviewed read. - | AuthAction::Transaction { .. } - | AuthAction::Savepoint { .. } - // An action code this rusqlite does not recognise. - | AuthAction::Unknown { .. } => Authorization::Deny, - _ => Authorization::Deny, - } -} - -fn install_authorizer(connection: &Connection) -> rusqlite::Result<()> { - connection.authorizer(Some(|context: AuthContext<'_>| authorize(&context.action))) -} - /// The extract's reserved metadata table, read before any row of data is. /// /// Nothing requires the reserved object to be an ordinary table: a view reads @@ -768,44 +589,51 @@ fn install_authorizer(connection: &Connection) -> rusqlite::Result<()> { /// So this read carries the same declared step and time bounds the statement /// itself runs under, and reports its own cause when it exceeds them. fn read_extract_metadata( - connection: &Connection, + profile: &DatabaseProfile, subject: &str, maximum_statement_steps: u64, timeout: Duration, ) -> Result { - let budget = install_progress_handler( - connection, - maximum_statement_steps, - Instant::now() + timeout, - ) - // A connection that will not take a progress handler cannot be stepped - // under a bound, so it is not a connection this source can read from. - .map_err(|_| extract_fault(subject, cause::EXTRACT_UNAVAILABLE))?; let sql = format!("SELECT published_at, publisher, extract_id FROM {EXTRACT_METADATA_TABLE}"); - let mut statement = connection.prepare(&sql).map_err(|error| { - // A missing table and a missing column are different problems for an - // extract publisher, so they are reported differently. - match classify_prepare(&error).cause { - cause::UNKNOWN_TABLE => extract_fault(subject, cause::NO_METADATA_TABLE), - _ => extract_fault(subject, cause::MALFORMED_METADATA), - } - })?; + let statement = ReadOnlyStatement::open( + profile.clone(), + StatementContract { + sql, + columns: vec![ + ColumnContract { + name: "published_at".to_owned(), + value_type: ColumnType::String, + }, + ColumnContract { + name: "publisher".to_owned(), + value_type: ColumnType::String, + }, + ColumnContract { + name: "extract_id".to_owned(), + value_type: ColumnType::String, + }, + ], + parameters: Vec::new(), + limits: StatementLimits { + maximum_rows: 1, + maximum_cell_bytes: MAXIMUM_METADATA_FIELD_BYTES, + maximum_response_bytes: MAXIMUM_METADATA_RESPONSE_BYTES, + maximum_statement_steps, + timeout, + concurrency: 1, + }, + schema: None, + }, + ) + .map_err(|error| map_metadata_error(error, subject))?; + let result = statement + .execute_at_open(&BTreeMap::new()) + .map_err(|error| map_metadata_error(error, subject))?; let malformed = || extract_fault(subject, cause::MALFORMED_METADATA); - // A step that failed because a bound stopped it says nothing about whether - // the metadata is well formed, so the two are told apart. - let stepped = || match budget.load(Ordering::Relaxed) { - BUDGET_WITHIN => malformed(), - _ => extract_fault(subject, cause::METADATA_BUDGET_EXCEEDED), - }; - - let mut rows = statement.raw_query(); - let row = rows.next().map_err(|_| stepped())?.ok_or_else(malformed)?; - let published_at = metadata_field(row, 0, subject)?; - let publisher = metadata_field(row, 1, subject)?; - let extract_id = metadata_field(row, 2, subject)?; - if rows.next().map_err(|_| stepped())?.is_some() { - return Err(malformed()); - } + let row = result.rows.into_iter().next().ok_or_else(malformed)?; + let published_at = metadata_value(&row, "published_at", subject)?; + let publisher = metadata_value(&row, "publisher", subject)?; + let extract_id = metadata_value(&row, "extract_id", subject)?; // The instant is truncated where it is parsed, so the instant the age bound // is measured against is the one the response carries. A relying party @@ -819,425 +647,40 @@ fn read_extract_metadata( Ok(ExtractMetadata::new(published_at, publisher, extract_id)) } -fn metadata_field(row: &Row<'_>, index: usize, subject: &str) -> Result { +fn metadata_value( + row: &BTreeMap, + name: &str, + subject: &str, +) -> Result { let malformed = || extract_fault(subject, cause::MALFORMED_METADATA); - let ValueRef::Text(bytes) = row.get_ref(index).map_err(|_| malformed())? else { + let Some(PlatformValue::String(value)) = row.get(name) else { return Err(malformed()); }; - if bytes.is_empty() || bytes.len() > MAXIMUM_METADATA_FIELD_BYTES { + if value.is_empty() || value.len() > MAXIMUM_METADATA_FIELD_BYTES { return Err(malformed()); } - std::str::from_utf8(bytes) - .map(str::to_owned) - .map_err(|_| malformed()) -} - -/// The strong check: prepare the statement against the real extract, and prove -/// its result columns and its parameters are the ones the bundle declared. -fn verify_statement( - connection: &Connection, - request: &SqliteRequest, - statement_sql: &str, -) -> Result, SqliteSourceError> { - let artifact = request.statement.as_str(); - if contains_positional_parameter(statement_sql) { - return Err(statement_fault(artifact, cause::UNDECLARED_PARAMETER)); - } - let statement = connection - .prepare(statement_sql) - .map_err(|error| classify_prepare(&error).statement_fault(artifact, statement_sql))?; - verify_columns(&statement, request, artifact)?; - verify_parameters(&statement, request, artifact) + Ok(value.clone()) } -/// Whether executable SQL contains a `?` or `?NNN` parameter token. -/// -/// SQLite can alias `?1` to a slot first introduced as `:name`, after which -/// its parameter API reports only the named spelling. Scan the reviewed text -/// as well, ignoring quoted values, quoted identifiers, and comments, so that -/// alias cannot bypass the named-only statement contract. -fn contains_positional_parameter(sql: &str) -> bool { - #[derive(Clone, Copy)] - enum State { - Sql, - Quote(u8), - Bracket, - LineComment, - BlockComment, - } - - let bytes = sql.as_bytes(); - let mut state = State::Sql; - let mut index = 0; - while index < bytes.len() { - let byte = bytes[index]; - let next = bytes.get(index + 1).copied(); - match state { - State::Sql => match (byte, next) { - (b'?', _) => return true, - (b'\'', _) | (b'"', _) | (b'`', _) => state = State::Quote(byte), - (b'[', _) => state = State::Bracket, - (b'-', Some(b'-')) => { - state = State::LineComment; - index += 1; - } - (b'/', Some(b'*')) => { - state = State::BlockComment; - index += 1; - } - _ => {} - }, - State::Quote(quote) if byte == quote => { - if next == Some(quote) { - index += 1; - } else { - state = State::Sql; - } - } - State::Quote(_) => {} - State::Bracket if byte == b']' => state = State::Sql, - State::Bracket => {} - State::LineComment if matches!(byte, b'\n' | b'\r') => state = State::Sql, - State::LineComment => {} - State::BlockComment if byte == b'*' && next == Some(b'/') => { - state = State::Sql; - index += 1; - } - State::BlockComment => {} - } - index += 1; - } - false -} - -/// The declared `columns` are what `responseSchema`, the extraction script and -/// the fact schema are written against, so a statement whose real result -/// disagrees with them is refused rather than silently reshaped. -fn verify_columns( - statement: &Statement<'_>, - request: &SqliteRequest, - artifact: &str, -) -> Result<(), SqliteSourceError> { - let mismatch = || statement_fault(artifact, cause::COLUMN_MISMATCH); - if statement.column_count() != request.columns.len() { - return Err(mismatch()); - } - for (index, declared) in request.columns.iter().enumerate() { - let real = statement.column_name(index).map_err(|_| mismatch())?; - if real != declared.name { - return Err(mismatch()); - } - } - Ok(()) -} - -/// The statement's real parameters and the declared `parameterBindings` must -/// name each other exactly, allowing for the reserved evaluation instant. -fn verify_parameters( - statement: &Statement<'_>, - request: &SqliteRequest, - artifact: &str, -) -> Result, SqliteSourceError> { - let mut parameters = Vec::new(); - for index in 1..=statement.parameter_count() { - // A positional parameter has no name to match a binding against. - let name = statement - .parameter_name(index) - .and_then(bare_parameter_name) - .ok_or_else(|| statement_fault(artifact, cause::UNDECLARED_PARAMETER))?; - if name != RESERVED_SQL_PARAMETER && !request.parameter_bindings.contains_key(name) { - return Err(statement_fault(artifact, cause::UNDECLARED_PARAMETER)); - } - parameters.push(BoundParameter { - index, - name: name.to_owned(), - }); - } - for declared in request.parameter_bindings.keys() { - if !parameters.iter().any(|bound| bound.name == declared) { - return Err(statement_fault(artifact, cause::UNUSED_BINDING)); - } - } - Ok(parameters) -} - -/// `:name`, `@name` and `$name` without the sigil. A numbered `?NNN` keeps its -/// digits, which no declared binding key can match, so it reads as undeclared. -fn bare_parameter_name(name: &str) -> Option<&str> { - let mut characters = name.chars(); - match characters.next()? { - ':' | '@' | '$' => Some(characters.as_str()), - _ => None, - } -} - -const BUDGET_WITHIN: u8 = 0; -const BUDGET_STEPS: u8 = 1; -const BUDGET_TIME: u8 = 2; - -/// Install the only cancellation this transport has. -/// -/// `tokio::time::timeout` cannot cancel a `spawn_blocking` task, and SQLite has -/// no way to be interrupted from outside a step. The progress callback runs on -/// the same thread as the statement, between virtual machine instructions, and -/// returning `true` aborts the step. Both bounds are checked there. -/// -/// The handler is not cleared afterwards. Nothing steps this connection between -/// executions, and every step this transport takes installs its own handler -/// first, so a handler left behind can never fire. -/// -/// The bounds arrive as values rather than as a [`StatementPlan`], because the -/// extract's metadata is read at startup before the plan's parameters are known -/// and that read is stepped under the same declared bounds. -fn install_progress_handler( - connection: &Connection, - maximum_statement_steps: u64, - deadline: Instant, -) -> Result, &'static str> { - let outcome = Arc::new(AtomicU8::new(BUDGET_WITHIN)); - let observed = Arc::clone(&outcome); - // A budget smaller than the interval would never be checked, so a small - // budget shortens the interval to itself. - let interval = maximum_statement_steps.clamp(1, PROGRESS_STEP_INTERVAL); - let budget = maximum_statement_steps; - let mut consumed: u64 = 0; - connection - .progress_handler( - c_int::try_from(interval).unwrap_or(c_int::MAX), - Some(move || { - consumed = consumed.saturating_add(interval); - if consumed >= budget { - observed.store(BUDGET_STEPS, Ordering::Relaxed); - return true; - } - if Instant::now() >= deadline { - observed.store(BUDGET_TIME, Ordering::Relaxed); - return true; - } - false - }), - ) - .map_err(|_| cause::EXECUTION_FAILED)?; - Ok(outcome) -} - -fn run_statement( - connection: &Connection, - plan: &StatementPlan, - bindings: &[(usize, BoundValue)], - deadline: Instant, -) -> Result, &'static str> { - // A task may have spent the whole source window waiting for a blocking - // worker. Refuse it before preparing or stepping anything when it finally - // starts, while the async caller independently returns at the same deadline. - if Instant::now() >= deadline { - return Err(cause::TIME_BUDGET_EXCEEDED); - } - let budget = install_progress_handler(connection, plan.maximum_statement_steps, deadline)?; - let mut statement = connection - .prepare(&plan.sql) - .map_err(|error| classify_prepare(&error).cause)?; - for (index, value) in bindings { - match value { - BoundValue::Text(text) => statement.raw_bind_parameter(*index, text), - BoundValue::Integer(number) => statement.raw_bind_parameter(*index, number), - } - .map_err(|_| cause::EXECUTION_FAILED)?; - } - - let mut rows = statement.raw_query(); - let mut collected: Vec = Vec::new(); - // Text is the only value whose size a row bound and a cell bound do not - // already settle, so it is the only thing worth running a total on. - let mut text_bytes: usize = 0; - loop { - let row = match rows.next() { - Ok(Some(row)) => row, - Ok(None) => break, - Err(error) => return Err(classify_step(&error, &budget)), - }; - // The statement is never rewritten, so the row bound is enforced by - // stepping one row past it and refusing that row. - if collected.len() as u64 >= plan.maximum_rows { - return Err(cause::TOO_MANY_ROWS); - } - collected.push(read_row(row, plan, &mut text_bytes)?); - } - Ok(collected) -} - -/// Read one row, and carry the running text total the response bound is read -/// against. -/// -/// `text_bytes` counts the text payload alone. Serializing the result adds key -/// names, quotes, any escaping, and the extract block, so the total counted here -/// is never more than the length the caller measures. Refusing on it can -/// therefore only refuse sooner than the caller would, never differently, and a -/// result the caller accepts is collected unchanged. -fn read_row( - row: &Row<'_>, - plan: &StatementPlan, - text_bytes: &mut usize, -) -> Result { - let mut object = JsonMap::with_capacity(plan.columns.len()); - for (index, column) in plan.columns.iter().enumerate() { - let raw = row.get_ref(index).map_err(|_| cause::EXECUTION_FAILED)?; - let value = read_value(raw, column.value_type, plan.maximum_cell_bytes)?; - if let JsonValue::String(text) = &value { - *text_bytes = text_bytes.saturating_add(text.len()); - if *text_bytes > plan.maximum_response_bytes { - return Err(cause::RESPONSE_TOO_LARGE); - } - } - object.insert(column.name.clone(), value); - } - Ok(JsonValue::Object(object)) -} - -/// Read one value as the type the bundle declared for its column. -/// -/// A value whose real SQLite type cannot be represented as the declared type is -/// a failure, not a coercion: the declared type is what the response schema and -/// the extraction script are written against. The cell bound is checked against -/// the borrowed bytes before an owned value is built, so an oversized value is -/// never copied into the process. -fn read_value( - value: ValueRef<'_>, - declared: SqliteColumnType, - maximum_cell_bytes: usize, -) -> Result { - match (value, declared) { - (ValueRef::Null, _) => Ok(JsonValue::Null), - (ValueRef::Text(bytes), SqliteColumnType::String) => { - if bytes.len() > maximum_cell_bytes { - return Err(cause::CELL_TOO_LARGE); - } - std::str::from_utf8(bytes) - .map(|text| JsonValue::String(text.to_owned())) - .map_err(|_| cause::VALUE_TYPE_MISMATCH) - } - (ValueRef::Integer(number), SqliteColumnType::Integer) => Ok(JsonValue::from(number)), - (ValueRef::Integer(number), SqliteColumnType::Number) => Ok(JsonValue::from(number)), - (ValueRef::Integer(number), SqliteColumnType::Boolean) => match number { - 0 => Ok(JsonValue::Bool(false)), - 1 => Ok(JsonValue::Bool(true)), - _ => Err(cause::VALUE_TYPE_MISMATCH), - }, - (ValueRef::Real(number), SqliteColumnType::Number) => JsonNumber::from_f64(number) - .map(JsonValue::Number) - .ok_or(cause::VALUE_TYPE_MISMATCH), - _ => Err(cause::VALUE_TYPE_MISMATCH), - } -} - -/// A classified preparation failure: one closed cause, and the byte offset -/// SQLite reported when a single character is what went wrong. -struct PrepareFault { - cause: &'static str, - offset: Option, -} - -impl PrepareFault { - fn because(cause: &'static str) -> Self { - Self { - cause, - offset: None, - } - } - - /// The failure as it is reported, placed inside the statement where SQLite - /// pointed at a character. - /// - /// `statement_sql` supplies the line breaks the offset is counted against - /// and nothing else: what travels out of here is a line and a column. - fn statement_fault(&self, artifact: &str, statement_sql: &str) -> SqliteSourceError { - match self.offset { - Some(offset) => SqliteSourceError::Statement(ArtifactFault::at( - artifact, - SchemaFault::because(self.cause), - text_location(statement_sql, offset), - )), - None => statement_fault(artifact, self.cause), - } - } -} - -/// Classify a preparation failure and discard the message SQLite wrote. -/// -/// SQLite reports an unknown table, an unknown column and a syntax error under -/// one result code, so its fixed message prefix is the only thing that separates -/// them. The prefix is matched and the remainder, which quotes the identifier -/// that was not found, is thrown away. -/// -/// The offset is kept only for a syntax error. SQLite also offers one for an -/// unresolved name, but an unknown table is a fact about the extract rather -/// than about a character of the statement, and a refused statement and an -/// exceeded bound have no character behind them at all. -fn classify_prepare(error: &rusqlite::Error) -> PrepareFault { - match error { - rusqlite::Error::MultipleStatement => PrepareFault::because(cause::MULTIPLE_STATEMENTS), - rusqlite::Error::SqliteFailure(failure, message) => { - PrepareFault::because(classify_failure(failure.code, message.as_deref())) - } - rusqlite::Error::SqlInputError { - error, msg, offset, .. - } => { - let cause = classify_failure(error.code, Some(msg)); - // SQLite writes a negative offset where it has no position to give. - let offset = (cause == cause::INVALID_SQL) - .then(|| usize::try_from(*offset).ok()) - .flatten(); - PrepareFault { cause, offset } - } - _ => PrepareFault::because(cause::INVALID_SQL), - } -} - -fn classify_failure(code: ErrorCode, message: Option<&str>) -> &'static str { - match code { - ErrorCode::AuthorizationForStatementDenied => cause::AUTHORIZER_REFUSED, - // SQLITE_ERROR, the code every statement-level complaint arrives under. - ErrorCode::Unknown => match message { - Some(text) if text.starts_with("no such table") => cause::UNKNOWN_TABLE, - Some(text) if text.starts_with("no such column") => cause::UNKNOWN_COLUMN, - // A refused function is reported while names are resolved, under - // SQLITE_ERROR rather than SQLITE_AUTH, so only the prefix says the - // authorizer is what stopped it. - Some(text) if text.starts_with("not authorized") => cause::AUTHORIZER_REFUSED, - _ => cause::INVALID_SQL, - }, - _ => cause::INVALID_SQL, - } -} - -/// Which bound stopped the statement, if a bound did. -/// -/// The progress callback records why it aborted before SQLite turns the abort -/// into `SQLITE_INTERRUPT`, so the two budgets stay distinguishable without -/// reading any message text. -fn classify_step(error: &rusqlite::Error, budget: &AtomicU8) -> &'static str { - match budget.load(Ordering::Relaxed) { - BUDGET_STEPS => cause::STEP_BUDGET_EXCEEDED, - BUDGET_TIME => cause::TIME_BUDGET_EXCEEDED, - _ => match error { - // `SQLITE_TOOBIG` while stepping is [`MAXIMUM_ENGINE_VALUE_BYTES`] - // refusing to materialize a value, so the row that carries it is - // over the cell bound whatever the declared bound happens to be. - // Only stepping may read the code this way: the same code at - // preparation time means the statement text was too long, which - // this cause would misname, so it is classified here rather than - // in the shared [`classify_failure`]. - rusqlite::Error::SqliteFailure(failure, _) if failure.code == ErrorCode::TooBig => { - cause::CELL_TOO_LARGE - } - // Preparation has already settled statement syntax, names and - // authorization. Any other engine failure while rows are being - // stepped is an execution failure, not newly invalid SQL. Discard - // SQLite's message because it may quote extract content. - rusqlite::Error::SqliteFailure(_, _) => cause::EXECUTION_FAILED, - _ => cause::EXECUTION_FAILED, - }, - } +fn map_metadata_error( + error: registry_platform_sqlite::SqliteError, + subject: &str, +) -> SqliteSourceError { + let cause = match error.kind() { + PlatformErrorKind::UnknownTable => cause::NO_METADATA_TABLE, + PlatformErrorKind::StepBudgetExceeded + | PlatformErrorKind::TimeBudgetExceeded + | PlatformErrorKind::Timeout => cause::METADATA_BUDGET_EXCEEDED, + PlatformErrorKind::DatabaseUnavailable + | PlatformErrorKind::DatabaseReplaced + | PlatformErrorKind::DatabaseWritable + | PlatformErrorKind::DatabaseSymlink + | PlatformErrorKind::DatabaseNotFile + | PlatformErrorKind::DatabaseChanged + | PlatformErrorKind::UncheckpointedSidecar => cause::EXTRACT_UNAVAILABLE, + _ => cause::MALFORMED_METADATA, + }; + extract_fault(subject, cause) } #[cfg(test)] @@ -1416,6 +859,11 @@ factSchema: schemas/facts.schema.yaml .execute_batch(statements) .expect("the extract fixture is valid SQL"); drop(connection); + let mut permissions = std::fs::metadata(&path) + .expect("the extract fixture has metadata") + .permissions(); + permissions.set_readonly(true); + std::fs::set_permissions(&path, permissions).expect("the extract fixture is immutable"); path } @@ -1733,8 +1181,9 @@ factSchema: schemas/facts.schema.yaml // execution queued behind the occupied blocking worker. A reset // after admission would let this call run until the test's much // larger safety ceiling instead of enforcing its own 25 ms limit. - let held = Arc::clone(&source.concurrency) - .acquire_many_owned(2) + let held = source + .statement + .hold_all_permits_for_test() .await .expect("the test holds every source permit"); let release_admission = async move { @@ -1822,18 +1271,6 @@ factSchema: schemas/facts.schema.yaml assert_eq!(answered["rows"], json!([{"total": 1}])); } - #[test] - fn an_engine_failure_while_stepping_is_not_reported_as_invalid_sql() { - let error = rusqlite::Error::SqliteFailure( - rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CORRUPT), - Some("database disk image is malformed around protected-value".to_owned()), - ); - let budget = AtomicU8::new(BUDGET_WITHIN); - - assert_eq!(classify_step(&error, &budget), cause::EXECUTION_FAILED); - assert!(!cause::EXECUTION_FAILED.contains("protected-value")); - } - #[tokio::test] async fn one_row_beyond_the_row_bound_is_refused() { let directory = TempDir::new().expect("a temporary directory"); @@ -1860,13 +1297,13 @@ factSchema: schemas/facts.schema.yaml async fn a_result_beyond_the_response_bound_is_refused_as_it_is_collected() { let directory = TempDir::new().expect("a temporary directory"); let path = extract(&directory); - let plan = Plan::default().response_bytes(8); + let plan = Plan::default().response_bytes(39); let source = open(&plan, "SELECT id FROM person ORDER BY id", &path); assert_eq!(run_error(&source).await, cause::RESPONSE_TOO_LARGE); - // The three identifiers are nine bytes of text between them, and the - // count is of text alone, so a bound of nine admits exactly them. - let exact = Plan::default().response_bytes(9); + // The exact compact JSON collection is 40 bytes: three one-property + // objects containing the three identifiers, plus delimiters and keys. + let exact = Plan::default().response_bytes(40); let source = open(&exact, "SELECT id FROM person ORDER BY id", &path); let result = run(&source, "the response bound admits its own size").await; assert_eq!( @@ -1979,15 +1416,6 @@ factSchema: schemas/facts.schema.yaml ); } - #[test] - fn positional_parameter_scan_ignores_literals_identifiers_and_comments() { - assert!(!contains_positional_parameter( - "SELECT '?' AS \"?\", `?`, [?] -- ?1\n/* ?2 */" - )); - assert!(contains_positional_parameter("SELECT :record, ?1")); - assert!(contains_positional_parameter("SELECT ?")); - } - #[tokio::test] async fn a_declared_parameter_carries_its_supplied_value() { let directory = TempDir::new().expect("a temporary directory"); @@ -2129,6 +1557,44 @@ factSchema: schemas/facts.schema.yaml } } + #[test] + fn maximum_metadata_fields_survive_structural_response_accounting() { + let directory = TempDir::new().expect("a temporary directory"); + let path = directory.path().join("extract.sqlite"); + let connection = Connection::open(&path).expect("the extract opens for writing"); + connection + .execute_batch(&format!( + "CREATE TABLE {EXTRACT_METADATA_TABLE} \ + (published_at TEXT, publisher TEXT, extract_id TEXT); \ + {EXTRACT_SCHEMA}" + )) + .expect("the extract schema is valid"); + let escaped = "\u{0001}".repeat(MAXIMUM_METADATA_FIELD_BYTES); + connection + .execute( + &format!("INSERT INTO {EXTRACT_METADATA_TABLE} VALUES (?1, ?2, ?3)"), + rusqlite::params!["2026-08-07T02:00:00Z", &escaped, &escaped], + ) + .expect("maximum-size metadata inserts"); + drop(connection); + let mut permissions = std::fs::metadata(&path) + .expect("the extract fixture has metadata") + .permissions(); + permissions.set_readonly(true); + std::fs::set_permissions(&path, permissions).expect("the extract becomes immutable"); + + let source = open(&Plan::default(), "SELECT id FROM person", &path); + + assert_eq!( + source.extract_metadata().publisher().len(), + MAXIMUM_METADATA_FIELD_BYTES + ); + assert_eq!( + source.extract_metadata().extract_id().len(), + MAXIMUM_METADATA_FIELD_BYTES + ); + } + #[test] fn a_metadata_table_missing_a_column_is_refused() { let directory = TempDir::new().expect("a temporary directory"); @@ -2478,28 +1944,6 @@ factSchema: schemas/facts.schema.yaml ); } - /// An offset at or past the end of the text is the position just after the - /// last character, not a panic. - #[test] - fn an_offset_at_or_past_the_end_of_the_text_still_has_a_position() { - let text = "SELECT id\nFROM person"; - for offset in [text.len(), text.len() + 1, usize::MAX] { - assert_eq!( - text_location(text, offset), - TextLocation { - line: 2, - column: 12 - }, - "offset {offset} did not land after the last character" - ); - } - assert_eq!(text_location("", 0), TextLocation { line: 1, column: 1 }); - assert_eq!( - text_location("SELECT id\n", 10), - TextLocation { line: 2, column: 1 }, - ); - } - /// Every other fault is a property of the statement as a whole, so it names /// no character rather than inventing one. #[tokio::test] diff --git a/crates/registry-evidence/tests/statement_source.rs b/crates/registry-evidence/tests/statement_source.rs index d80dc76ad..9fee82852 100644 --- a/crates/registry-evidence/tests/statement_source.rs +++ b/crates/registry-evidence/tests/statement_source.rs @@ -5,6 +5,7 @@ //! and value-free diagnostics that still name the artifact to open. use std::collections::BTreeMap; +use std::fs; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -184,6 +185,11 @@ fn extract(directory: &TempDir, published_at: &str) -> PathBuf { .execute_batch(&statements) .expect("the extract fixture is valid SQL"); drop(connection); + let mut permissions = fs::metadata(&path) + .expect("the extract fixture has metadata") + .permissions(); + permissions.set_readonly(true); + fs::set_permissions(&path, permissions).expect("the extract fixture is immutable"); path } diff --git a/crates/registry-platform-sqlite/Cargo.toml b/crates/registry-platform-sqlite/Cargo.toml new file mode 100644 index 000000000..329ac93f7 --- /dev/null +++ b/crates/registry-platform-sqlite/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "registry-platform-sqlite" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "Bounded read-only SQLite security primitives for Registry Platform runtimes." +repository.workspace = true +publish = false +readme = "README.md" + +[lints.rust] +# SQLite exposes actual-open-handle identity only through file-control. Keep +# unsafe denied throughout the crate, with one documented scoped exception at +# that FFI boundary; the workspace-wide forbid cannot express such a scope. +unsafe_code = "deny" + +[features] +default = [] +fixture = [] + +[dependencies] +rusqlite.workspace = true +rustix.workspace = true +serde.workspace = true +sha2.workspace = true +thiserror.workspace = true +tokio.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/registry-platform-sqlite/README.md b/crates/registry-platform-sqlite/README.md new file mode 100644 index 000000000..78578956f --- /dev/null +++ b/crates/registry-platform-sqlite/README.md @@ -0,0 +1,39 @@ +# Registry Platform SQLite + +`registry-platform-sqlite` is the product-neutral SQLite security boundary used +by Registry Stack runtimes. It captures immutable snapshots, opens snapshot or +live databases read-only, validates reviewed statements, rejects mutating and +non-deterministic SQL, and executes under explicit queue, time, step, row, cell, +and response bounds. + +Snapshot profiles bind a regular, unwritable, sidecar-free file to its identity +and SHA-256 digest and open it with SQLite's immutable mode. Live profiles bind +the main path identity, require an expected schema fingerprint, and re-verify +that fingerprint inside the same read transaction as each statement. Execution +provenance reports the profile, verified schema fingerprint when configured, +snapshot revision when available, and a domain-separated statement digest. + +Schema inspection reads only `main.sqlite_schema` and SQLite's column metadata. +It returns ordered object and column declarations under fixed engine limits and +caller-supplied object, metadata-byte, step, and time bounds. It never samples a +table or view row. + +The crate is SQLite-specific. It does not define a generic storage interface, +and it never places SQL, database paths, parameters, or returned values in an +error. + +Pathname checks alone cannot detect a database substituted while SQLite opens +its pool and restored immediately afterward. On Unix, the crate therefore asks +SQLite's active VFS whether each actual `main` handle has moved, after pool +construction and before and after every statement. Rusqlite does not expose a +safe wrapper for `SQLITE_FCNTL_HAS_MOVED`, so one small documented FFI function +is the crate's only `unsafe` exception. The crate denies unsafe code everywhere +else, checks the SQLite return code conservatively, and never dereferences the +opaque connection pointer itself. + +Production use is Unix-only until another target has an equivalent actual-open +handle proof. Non-Unix builds fail closed when opening a SQLite connection +rather than silently falling back to pathname checks. + +The optional `fixture` feature enables materializing reviewed fixture seed SQL. +Production readers never use that authorizer-free connection. diff --git a/crates/registry-platform-sqlite/src/capture.rs b/crates/registry-platform-sqlite/src/capture.rs new file mode 100644 index 000000000..8959b595a --- /dev/null +++ b/crates/registry-platform-sqlite/src/capture.rs @@ -0,0 +1,283 @@ +use std::fs::{self, File, Metadata}; +use std::io::Read as _; +use std::path::{Path, PathBuf}; + +use sha2::{Digest as _, Sha256}; + +use crate::{ErrorKind, SqliteError}; + +const DIGEST_CHUNK_BYTES: usize = 64 * 1024; +const SNAPSHOT_SIDECARS: [&str; 2] = ["-wal", "-journal"]; + +#[derive(Debug, Clone)] +struct FileIdentity(Metadata); + +impl PartialEq for FileIdentity { + fn eq(&self, other: &Self) -> bool { + same_file(&self.0, &other.0) + } +} +impl Eq for FileIdentity {} + +/// A read-only immutable snapshot captured and digested at startup. +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct CapturedSnapshot { + path: PathBuf, + digest: String, + identity: FileIdentity, +} + +impl CapturedSnapshot { + pub fn capture(path: impl AsRef) -> Result { + let path = path.as_ref(); + let scanned = fs::symlink_metadata(path) + .map_err(|_| SqliteError::new(ErrorKind::DatabaseUnavailable))?; + if scanned.file_type().is_symlink() { + return Err(SqliteError::new(ErrorKind::DatabaseSymlink)); + } + if !scanned.is_file() { + return Err(SqliteError::new(ErrorKind::DatabaseNotFile)); + } + let filesystem_read_only = filesystem_read_only(path)?; + if !filesystem_read_only && metadata_is_writable(&scanned) { + return Err(SqliteError::new(ErrorKind::DatabaseWritable)); + } + refuse_sidecars(path)?; + let (digest, identity) = digest_stable(path, &scanned, filesystem_read_only)?; + refuse_sidecars(path)?; + Ok(Self { + path: path.to_path_buf(), + digest, + identity, + }) + } + + #[must_use] + pub fn path(&self) -> &Path { + &self.path + } + + #[must_use] + pub fn digest(&self) -> &str { + &self.digest + } + + pub fn confirm_still_bound(&self) -> Result<(), SqliteError> { + refuse_sidecars(&self.path)?; + let current = fs::symlink_metadata(&self.path) + .map_err(|_| SqliteError::new(ErrorKind::DatabaseUnavailable))?; + if current.file_type().is_symlink() + || !current.is_file() + || FileIdentity(current) != self.identity + { + return Err(SqliteError::new(ErrorKind::DatabaseReplaced)); + } + Ok(()) + } + + /// Re-read the bound snapshot and prove that its exact captured bytes are + /// still present. This is intended for readiness probes, where the extra + /// I/O is acceptable and identity checks alone are not a sufficient proof + /// that an immutable deployment input has not drifted. + pub fn verify_unchanged(&self) -> Result<(), SqliteError> { + self.confirm_still_bound()?; + let scanned = fs::symlink_metadata(&self.path) + .map_err(|_| SqliteError::new(ErrorKind::DatabaseUnavailable))?; + let filesystem_read_only = filesystem_read_only(&self.path)?; + let (digest, identity) = digest_stable(&self.path, &scanned, filesystem_read_only)?; + refuse_sidecars(&self.path)?; + if identity != self.identity || digest != self.digest { + return Err(SqliteError::new(ErrorKind::DatabaseChanged)); + } + Ok(()) + } +} + +/// A live database bound to one path identity for the process lifetime. +/// +/// File contents may change, including through WAL, but replacing the main +/// database path is refused until restart. +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct LiveDatabaseFile { + path: PathBuf, + identity: FileIdentity, +} + +impl LiveDatabaseFile { + pub fn bind(path: impl AsRef) -> Result { + let path = path.as_ref(); + let metadata = fs::symlink_metadata(path) + .map_err(|_| SqliteError::new(ErrorKind::DatabaseUnavailable))?; + if metadata.file_type().is_symlink() { + return Err(SqliteError::new(ErrorKind::DatabaseSymlink)); + } + if !metadata.is_file() { + return Err(SqliteError::new(ErrorKind::DatabaseNotFile)); + } + Ok(Self { + path: path.to_path_buf(), + identity: FileIdentity(metadata), + }) + } + + #[must_use] + pub fn path(&self) -> &Path { + &self.path + } + + pub fn confirm_still_bound(&self) -> Result<(), SqliteError> { + let current = fs::symlink_metadata(&self.path) + .map_err(|_| SqliteError::new(ErrorKind::DatabaseUnavailable))?; + if current.file_type().is_symlink() + || !current.is_file() + || !same_live_file(&self.identity.0, ¤t) + { + return Err(SqliteError::new(ErrorKind::DatabaseReplaced)); + } + Ok(()) + } +} + +fn refuse_sidecars(path: &Path) -> Result<(), SqliteError> { + for suffix in SNAPSHOT_SIDECARS { + let mut sidecar = path.as_os_str().to_owned(); + sidecar.push(suffix); + if fs::symlink_metadata(PathBuf::from(sidecar)).is_ok() { + return Err(SqliteError::new(ErrorKind::UncheckpointedSidecar)); + } + } + Ok(()) +} + +fn digest_stable( + path: &Path, + scanned: &Metadata, + fs_read_only: bool, +) -> Result<(String, FileIdentity), SqliteError> { + let mut file = open_no_follow(path)?; + let opened = file + .metadata() + .map_err(|_| SqliteError::new(ErrorKind::DatabaseUnavailable))?; + if !opened.is_file() || !same_file(scanned, &opened) { + return Err(SqliteError::new(ErrorKind::DatabaseReplaced)); + } + if !fs_read_only && metadata_is_writable(&opened) { + return Err(SqliteError::new(ErrorKind::DatabaseWritable)); + } + let mut hasher = Sha256::new(); + let mut buffer = [0_u8; DIGEST_CHUNK_BYTES]; + let mut read_total = 0_u64; + loop { + let read = file + .read(&mut buffer) + .map_err(|_| SqliteError::new(ErrorKind::DatabaseUnavailable))?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + read_total = read_total + .checked_add( + u64::try_from(read).map_err(|_| SqliteError::new(ErrorKind::DatabaseChanged))?, + ) + .ok_or_else(|| SqliteError::new(ErrorKind::DatabaseChanged))?; + } + let after = file + .metadata() + .map_err(|_| SqliteError::new(ErrorKind::DatabaseUnavailable))?; + if !same_file(&opened, &after) || after.len() != read_total { + return Err(SqliteError::new(ErrorKind::DatabaseChanged)); + } + Ok(( + sha256_label(hasher.finalize().as_slice()), + FileIdentity(opened), + )) +} + +fn sha256_label(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut label = String::with_capacity(7 + bytes.len() * 2); + label.push_str("sha256:"); + for byte in bytes { + label.push(char::from(HEX[usize::from(byte >> 4)])); + label.push(char::from(HEX[usize::from(byte & 0x0f)])); + } + label +} + +#[cfg(unix)] +fn open_no_follow(path: &Path) -> Result { + use rustix::fs::{Mode, OFlags}; + let fd = rustix::fs::open( + path, + OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NOFOLLOW | OFlags::NONBLOCK, + Mode::empty(), + ) + .map_err(|_| SqliteError::new(ErrorKind::DatabaseUnavailable))?; + Ok(File::from(fd)) +} + +#[cfg(not(unix))] +fn open_no_follow(path: &Path) -> Result { + let metadata = + fs::symlink_metadata(path).map_err(|_| SqliteError::new(ErrorKind::DatabaseUnavailable))?; + if metadata.file_type().is_symlink() { + return Err(SqliteError::new(ErrorKind::DatabaseSymlink)); + } + File::open(path).map_err(|_| SqliteError::new(ErrorKind::DatabaseUnavailable)) +} + +#[cfg(unix)] +fn filesystem_read_only(path: &Path) -> Result { + use rustix::fs::{statvfs, StatVfsMountFlags}; + Ok(statvfs(path) + .map_err(|_| SqliteError::new(ErrorKind::DatabaseUnavailable))? + .f_flag + .contains(StatVfsMountFlags::RDONLY)) +} + +#[cfg(not(unix))] +fn filesystem_read_only(_path: &Path) -> Result { + Ok(false) +} + +#[cfg(unix)] +fn same_file(left: &Metadata, right: &Metadata) -> bool { + use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _}; + left.dev() == right.dev() + && left.ino() == right.ino() + && left.len() == right.len() + && left.permissions().mode() == right.permissions().mode() + && left.mtime() == right.mtime() + && left.mtime_nsec() == right.mtime_nsec() + && left.ctime() == right.ctime() + && left.ctime_nsec() == right.ctime_nsec() +} + +#[cfg(not(unix))] +fn same_file(left: &Metadata, right: &Metadata) -> bool { + left.len() == right.len() + && left.permissions().readonly() == right.permissions().readonly() + && left.modified().ok() == right.modified().ok() +} + +#[cfg(unix)] +fn same_live_file(left: &Metadata, right: &Metadata) -> bool { + use std::os::unix::fs::MetadataExt as _; + left.dev() == right.dev() && left.ino() == right.ino() +} + +#[cfg(unix)] +fn metadata_is_writable(metadata: &Metadata) -> bool { + use std::os::unix::fs::PermissionsExt as _; + metadata.permissions().mode() & 0o222 != 0 +} + +#[cfg(not(unix))] +fn metadata_is_writable(metadata: &Metadata) -> bool { + !metadata.permissions().readonly() +} + +#[cfg(not(unix))] +fn same_live_file(left: &Metadata, right: &Metadata) -> bool { + left.created().ok() == right.created().ok() +} diff --git a/crates/registry-platform-sqlite/src/error.rs b/crates/registry-platform-sqlite/src/error.rs new file mode 100644 index 000000000..9b2288ec7 --- /dev/null +++ b/crates/registry-platform-sqlite/src/error.rs @@ -0,0 +1,153 @@ +use thiserror::Error; + +/// Stable, value-free cause text retained for compatibility adapters. +pub mod cause { + pub const MULTIPLE_STATEMENTS: &str = "the artifact holds more than one statement"; + pub const INVALID_SQL: &str = "the statement is not valid SQL"; + pub const UNKNOWN_TABLE: &str = "the statement names a table the extract does not have"; + pub const UNKNOWN_COLUMN: &str = "the statement names a column the extract does not have"; + pub const COLUMN_MISMATCH: &str = "the result columns disagree with the declared columns"; + pub const UNDECLARED_PARAMETER: &str = "a statement parameter has no declared binding"; + pub const UNUSED_BINDING: &str = "a declared binding names no statement parameter"; + pub const MISSING_PARAMETER: &str = "a statement parameter has no supplied value"; + pub const AUTHORIZER_REFUSED: &str = "the authorizer refused the statement"; + pub const STEP_BUDGET_EXCEEDED: &str = "the statement exceeded its step budget"; + pub const TIME_BUDGET_EXCEEDED: &str = "the statement exceeded its time budget"; + pub const TOO_MANY_ROWS: &str = "the result exceeded the declared row bound"; + pub const CELL_TOO_LARGE: &str = "a result value exceeded the declared cell size bound"; + pub const RESPONSE_TOO_LARGE: &str = "the result exceeded the declared response size bound"; + pub const VALUE_TYPE_MISMATCH: &str = "a result value disagrees with its declared column type"; + pub const EXECUTION_FAILED: &str = "the statement failed while its result was read"; + pub const DATABASE_UNAVAILABLE: &str = "the database file could not be opened"; + pub const DATABASE_REPLACED: &str = "the database path no longer names the captured file"; + pub const DATABASE_WRITABLE: &str = "the snapshot database file is writable"; + pub const DATABASE_SYMLINK: &str = "the database path is a symbolic link"; + pub const DATABASE_NOT_FILE: &str = "the database path is not a regular file"; + pub const DATABASE_CHANGED: &str = "the database file changed while it was captured"; + pub const UNCHECKPOINTED_SIDECAR: &str = "the snapshot database has an uncheckpointed sidecar"; + pub const SCHEMA_BUDGET_EXCEEDED: &str = "schema inspection exceeded its declared budget"; + pub const SCHEMA_MALFORMED: &str = "the database schema is malformed"; + pub const SCHEMA_MISMATCH: &str = "the database schema fingerprint does not match"; +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub struct TextLocation { + pub line: usize, + pub column: usize, +} + +/// Closed machine-readable SQLite failure categories. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +#[non_exhaustive] +pub enum ErrorKind { + InvalidPlan, + MultipleStatements, + InvalidSql, + UnknownTable, + UnknownColumn, + ColumnMismatch, + UndeclaredParameter, + UnusedBinding, + MissingParameter, + AuthorizerRefused, + StepBudgetExceeded, + TimeBudgetExceeded, + TooManyRows, + CellTooLarge, + ResponseTooLarge, + ValueTypeMismatch, + ExecutionFailed, + DatabaseUnavailable, + DatabaseReplaced, + DatabaseWritable, + DatabaseSymlink, + DatabaseNotFile, + DatabaseChanged, + UncheckpointedSidecar, + SchemaBudgetExceeded, + SchemaMalformed, + SchemaMismatch, + Concurrency, + Timeout, + WorkerUnavailable, +} + +impl ErrorKind { + #[must_use] + pub const fn cause(self) -> &'static str { + match self { + Self::InvalidPlan => "the SQLite read plan is invalid", + Self::MultipleStatements => cause::MULTIPLE_STATEMENTS, + Self::InvalidSql => cause::INVALID_SQL, + Self::UnknownTable => cause::UNKNOWN_TABLE, + Self::UnknownColumn => cause::UNKNOWN_COLUMN, + Self::ColumnMismatch => cause::COLUMN_MISMATCH, + Self::UndeclaredParameter => cause::UNDECLARED_PARAMETER, + Self::UnusedBinding => cause::UNUSED_BINDING, + Self::MissingParameter => cause::MISSING_PARAMETER, + Self::AuthorizerRefused => cause::AUTHORIZER_REFUSED, + Self::StepBudgetExceeded => cause::STEP_BUDGET_EXCEEDED, + Self::TimeBudgetExceeded => cause::TIME_BUDGET_EXCEEDED, + Self::TooManyRows => cause::TOO_MANY_ROWS, + Self::CellTooLarge => cause::CELL_TOO_LARGE, + Self::ResponseTooLarge => cause::RESPONSE_TOO_LARGE, + Self::ValueTypeMismatch => cause::VALUE_TYPE_MISMATCH, + Self::ExecutionFailed => cause::EXECUTION_FAILED, + Self::DatabaseUnavailable => cause::DATABASE_UNAVAILABLE, + Self::DatabaseReplaced => cause::DATABASE_REPLACED, + Self::DatabaseWritable => cause::DATABASE_WRITABLE, + Self::DatabaseSymlink => cause::DATABASE_SYMLINK, + Self::DatabaseNotFile => cause::DATABASE_NOT_FILE, + Self::DatabaseChanged => cause::DATABASE_CHANGED, + Self::UncheckpointedSidecar => cause::UNCHECKPOINTED_SIDECAR, + Self::SchemaBudgetExceeded => cause::SCHEMA_BUDGET_EXCEEDED, + Self::SchemaMalformed => cause::SCHEMA_MALFORMED, + Self::SchemaMismatch => cause::SCHEMA_MISMATCH, + Self::Concurrency => "the SQLite concurrency boundary is unavailable", + Self::Timeout => "the SQLite read exceeded its time limit", + Self::WorkerUnavailable => "the SQLite execution worker is unavailable", + } + } +} + +/// A categorical failure that retains no SQL, path, bound value, or row value. +#[derive(Debug, Clone, Eq, PartialEq, Error)] +#[error("{kind_cause}")] +pub struct SqliteError { + kind: ErrorKind, + kind_cause: &'static str, + location: Option, +} + +impl SqliteError { + pub(crate) const fn new(kind: ErrorKind) -> Self { + Self { + kind, + kind_cause: kind.cause(), + location: None, + } + } + + pub(crate) const fn at(kind: ErrorKind, location: TextLocation) -> Self { + Self { + kind, + kind_cause: kind.cause(), + location: Some(location), + } + } + + #[must_use] + pub const fn kind(&self) -> ErrorKind { + self.kind + } + + #[must_use] + pub const fn cause(&self) -> &'static str { + self.kind_cause + } + + #[must_use] + pub const fn location(&self) -> Option { + self.location + } +} diff --git a/crates/registry-platform-sqlite/src/lib.rs b/crates/registry-platform-sqlite/src/lib.rs new file mode 100644 index 000000000..76fd78fe9 --- /dev/null +++ b/crates/registry-platform-sqlite/src/lib.rs @@ -0,0 +1,25 @@ +//! Bounded read-only SQLite access for Registry Platform consumers. +//! +//! The crate owns the SQLite safety boundary, not a disclosure policy. A +//! consumer still reviews which schema objects and columns a compiled statement +//! may read. Every public failure is categorical and value-free. + +mod capture; +mod error; +mod schema; +mod statement; + +pub use capture::{CapturedSnapshot, LiveDatabaseFile}; +pub use error::{cause, ErrorKind, SqliteError, TextLocation}; +pub use schema::{ + inspect_schema, schema_fingerprint, InspectionLimits, SchemaCatalog, SchemaColumn, + SchemaObject, SchemaObjectKind, +}; +pub use statement::{ + check_statement_offline, ColumnContract, ColumnType, DatabaseProfile, DatabaseProfileKind, + ExecutionProvenance, ParameterContract, ReadOnlyStatement, ResultRow, ResultSet, SchemaBinding, + StatementContract, StatementLimits, Value, +}; + +#[cfg(feature = "fixture")] +pub use statement::materialize_fixture; diff --git a/crates/registry-platform-sqlite/src/schema.rs b/crates/registry-platform-sqlite/src/schema.rs new file mode 100644 index 000000000..11b8fdaed --- /dev/null +++ b/crates/registry-platform-sqlite/src/schema.rs @@ -0,0 +1,396 @@ +use std::path::Path; +use std::sync::atomic::{AtomicU8, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use rusqlite::limits::Limit; +use rusqlite::types::ValueRef; +use rusqlite::{Connection, OpenFlags}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest as _, Sha256}; + +use crate::statement::{confirm_connection_still_bound, database_uri}; +use crate::{DatabaseProfile, ErrorKind, SqliteError}; + +const ENGINE_LIMIT: i32 = 8 * 1_024 * 1_024; +const STEP_INTERVAL: u64 = 1_000; +const MAXIMUM_SCHEMA_OBJECTS: usize = 16_384; +const MAXIMUM_SCHEMA_COLUMNS: usize = 65_536; + +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct InspectionLimits { + pub maximum_objects: usize, + pub maximum_sql_bytes: usize, + pub maximum_statement_steps: u64, + pub timeout: Duration, +} + +impl InspectionLimits { + pub(crate) fn validate(&self) -> Result<(), SqliteError> { + if self.maximum_objects == 0 + || self.maximum_sql_bytes == 0 + || self.maximum_statement_steps == 0 + || self.timeout.is_zero() + || Instant::now().checked_add(self.timeout).is_none() + || self.maximum_objects > MAXIMUM_SCHEMA_OBJECTS + || self.maximum_sql_bytes > usize::try_from(ENGINE_LIMIT).unwrap_or(usize::MAX) + { + return Err(SqliteError::new(ErrorKind::InvalidPlan)); + } + Ok(()) + } +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SchemaObjectKind { + Table, + Index, + View, + Trigger, +} + +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SchemaObject { + pub kind: SchemaObjectKind, + pub name: String, + pub table_name: String, + pub sql: Option, + /// Columns in SQLite `cid` order. Indexes and triggers have no columns. + pub columns: Vec, +} + +/// Schema-only column metadata. No stored value is sampled to construct it. +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SchemaColumn { + pub name: String, + pub declared_type: String, + pub nullable: bool, + pub primary_key: bool, +} + +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SchemaCatalog { + pub fingerprint: String, + pub objects: Vec, +} + +/// Inspect only `main.sqlite_schema`. No table or view row is sampled. +pub fn inspect_schema( + profile: &DatabaseProfile, + limits: &InspectionLimits, +) -> Result { + limits.validate()?; + profile_confirm(profile)?; + let connection = open_for_schema(profile)?; + // Establish one read snapshot. The only SQL below is fixed schema-catalog + // inspection; no caller SQL or raw connection crosses this boundary. + connection + .execute_batch("BEGIN") + .map_err(|_| SqliteError::new(ErrorKind::DatabaseUnavailable))?; + let outcome = collect_schema(&connection, limits); + profile_confirm(profile)?; + confirm_connection_still_bound(&connection)?; + drop(connection); + let objects = outcome?; + let fingerprint = fingerprint_objects(&objects); + Ok(SchemaCatalog { + fingerprint, + objects, + }) +} + +pub fn schema_fingerprint( + profile: &DatabaseProfile, + limits: &InspectionLimits, +) -> Result { + inspect_schema(profile, limits).map(|catalog| catalog.fingerprint) +} + +fn profile_confirm(profile: &DatabaseProfile) -> Result<(), SqliteError> { + match profile { + DatabaseProfile::Snapshot(value) => value.confirm_still_bound(), + DatabaseProfile::LiveReadOnly(value) => value.confirm_still_bound(), + } +} + +fn profile_path(profile: &DatabaseProfile) -> &Path { + match profile { + DatabaseProfile::Snapshot(value) => value.path(), + DatabaseProfile::LiveReadOnly(value) => value.path(), + } +} + +fn open_for_schema(profile: &DatabaseProfile) -> Result { + let immutable = matches!(profile, DatabaseProfile::Snapshot(_)); + let uri = database_uri(profile_path(profile), immutable) + .ok_or_else(|| SqliteError::new(ErrorKind::DatabaseUnavailable))?; + let flags = OpenFlags::SQLITE_OPEN_READ_ONLY + | OpenFlags::SQLITE_OPEN_URI + | OpenFlags::SQLITE_OPEN_NO_MUTEX; + let connection = Connection::open_with_flags(uri, flags) + .map_err(|_| SqliteError::new(ErrorKind::DatabaseUnavailable))?; + connection + .set_limit(Limit::SQLITE_LIMIT_LENGTH, ENGINE_LIMIT) + .map_err(|_| SqliteError::new(ErrorKind::DatabaseUnavailable))?; + confirm_connection_still_bound(&connection)?; + Ok(connection) +} + +fn collect_schema( + connection: &Connection, + limits: &InspectionLimits, +) -> Result, SqliteError> { + const WITHIN: u8 = 0; + const STEPS: u8 = 1; + const TIME: u8 = 2; + let outcome = Arc::new(AtomicU8::new(WITHIN)); + let observed = Arc::clone(&outcome); + let interval = limits.maximum_statement_steps.clamp(1, STEP_INTERVAL); + let budget = limits.maximum_statement_steps; + let deadline = Instant::now() + .checked_add(limits.timeout) + .ok_or_else(|| SqliteError::new(ErrorKind::InvalidPlan))?; + let mut consumed = 0_u64; + connection + .progress_handler( + i32::try_from(interval).unwrap_or(i32::MAX), + Some(move || { + consumed = consumed.saturating_add(interval); + if consumed >= budget { + observed.store(STEPS, Ordering::Relaxed); + true + } else if Instant::now() >= deadline { + observed.store(TIME, Ordering::Relaxed); + true + } else { + false + } + }), + ) + .map_err(|_| SqliteError::new(ErrorKind::SchemaMalformed))?; + + collect_schema_with_installed_budget(connection, limits).map_err(|error| { + if error.kind() == ErrorKind::SchemaMalformed + && matches!(outcome.load(Ordering::Relaxed), STEPS | TIME) + { + SqliteError::new(ErrorKind::SchemaBudgetExceeded) + } else { + error + } + }) +} + +/// Read the schema while a caller-owned progress handler and transaction are +/// already active. Statement execution uses this so live fingerprint checking +/// and the data query share one time/step budget and one read snapshot. +pub(crate) fn collect_schema_with_installed_budget( + connection: &Connection, + limits: &InspectionLimits, +) -> Result, SqliteError> { + let mut statement = connection + .prepare( + "SELECT type, name, tbl_name, sql FROM main.sqlite_schema \ + WHERE type IN ('table','index','view','trigger') \ + ORDER BY type, name, tbl_name, coalesce(sql, '')", + ) + .map_err(|_| SqliteError::new(ErrorKind::SchemaMalformed))?; + let mut rows = statement.raw_query(); + let mut objects = Vec::new(); + let mut column_count = 0_usize; + let mut sql_bytes = 0_usize; + loop { + let row = match rows.next() { + Ok(Some(row)) => row, + Ok(None) => break, + Err(_) => return Err(SqliteError::new(ErrorKind::SchemaMalformed)), + }; + if objects.len() >= limits.maximum_objects { + return Err(SqliteError::new(ErrorKind::SchemaBudgetExceeded)); + } + let kind = match read_text( + row.get_ref(0) + .map_err(|_| SqliteError::new(ErrorKind::SchemaMalformed))?, + )? + .as_str() + { + "table" => SchemaObjectKind::Table, + "index" => SchemaObjectKind::Index, + "view" => SchemaObjectKind::View, + "trigger" => SchemaObjectKind::Trigger, + _ => return Err(SqliteError::new(ErrorKind::SchemaMalformed)), + }; + let name = read_text( + row.get_ref(1) + .map_err(|_| SqliteError::new(ErrorKind::SchemaMalformed))?, + )?; + let table_name = read_text( + row.get_ref(2) + .map_err(|_| SqliteError::new(ErrorKind::SchemaMalformed))?, + )?; + let sql = match row + .get_ref(3) + .map_err(|_| SqliteError::new(ErrorKind::SchemaMalformed))? + { + ValueRef::Null => None, + value => Some(read_text(value)?), + }; + sql_bytes = sql_bytes + .saturating_add(name.len()) + .saturating_add(table_name.len()) + .saturating_add(sql.as_ref().map_or(0, String::len)); + if sql_bytes > limits.maximum_sql_bytes { + return Err(SqliteError::new(ErrorKind::SchemaBudgetExceeded)); + } + let columns = if matches!(kind, SchemaObjectKind::Table | SchemaObjectKind::View) { + read_columns(connection, &name, limits, &mut sql_bytes, &mut column_count)? + } else { + Vec::new() + }; + objects.push(SchemaObject { + kind, + name, + table_name, + sql, + columns, + }); + } + Ok(objects) +} + +fn read_columns( + connection: &Connection, + object_name: &str, + limits: &InspectionLimits, + catalog_bytes: &mut usize, + catalog_columns: &mut usize, +) -> Result, SqliteError> { + // The table-valued PRAGMA accepts the object name as a bound value. The + // fixed query cannot sample rows from that object and never interpolates an + // identifier supplied by the database schema. + let mut statement = connection + .prepare( + "SELECT name, type, \"notnull\", pk FROM pragma_table_xinfo(?1, 'main') \ + ORDER BY cid", + ) + .map_err(|_| SqliteError::new(ErrorKind::SchemaMalformed))?; + let mut rows = statement + .query([object_name]) + .map_err(|_| SqliteError::new(ErrorKind::SchemaMalformed))?; + let mut columns = Vec::new(); + while let Some(row) = rows + .next() + .map_err(|_| SqliteError::new(ErrorKind::SchemaMalformed))? + { + // SQLite bounds columns per table. The platform adds one global catalog + // ceiling so many wide tables cannot multiply into an unbounded result. + if *catalog_columns >= MAXIMUM_SCHEMA_COLUMNS { + return Err(SqliteError::new(ErrorKind::SchemaBudgetExceeded)); + } + let name = read_text( + row.get_ref(0) + .map_err(|_| SqliteError::new(ErrorKind::SchemaMalformed))?, + )?; + let declared_type = read_text( + row.get_ref(1) + .map_err(|_| SqliteError::new(ErrorKind::SchemaMalformed))?, + )?; + let not_null = read_flag( + row.get_ref(2) + .map_err(|_| SqliteError::new(ErrorKind::SchemaMalformed))?, + )?; + let primary_key = read_nonnegative_integer( + row.get_ref(3) + .map_err(|_| SqliteError::new(ErrorKind::SchemaMalformed))?, + )? > 0; + *catalog_bytes = catalog_bytes + .saturating_add(name.len()) + .saturating_add(declared_type.len()); + if *catalog_bytes > limits.maximum_sql_bytes { + return Err(SqliteError::new(ErrorKind::SchemaBudgetExceeded)); + } + columns.push(SchemaColumn { + name, + declared_type, + nullable: !not_null, + primary_key, + }); + *catalog_columns += 1; + } + Ok(columns) +} + +fn read_text(value: ValueRef<'_>) -> Result { + let ValueRef::Text(bytes) = value else { + return Err(SqliteError::new(ErrorKind::SchemaMalformed)); + }; + std::str::from_utf8(bytes) + .map(str::to_owned) + .map_err(|_| SqliteError::new(ErrorKind::SchemaMalformed)) +} + +fn read_flag(value: ValueRef<'_>) -> Result { + match value { + ValueRef::Integer(0) => Ok(false), + ValueRef::Integer(1) => Ok(true), + _ => Err(SqliteError::new(ErrorKind::SchemaMalformed)), + } +} + +fn read_nonnegative_integer(value: ValueRef<'_>) -> Result { + match value { + ValueRef::Integer(value) if value >= 0 => Ok(value), + _ => Err(SqliteError::new(ErrorKind::SchemaMalformed)), + } +} + +pub(crate) fn fingerprint_objects(objects: &[SchemaObject]) -> String { + let mut hasher = Sha256::new(); + hasher.update(b"registry-platform-sqlite-schema-v1\0"); + for object in objects { + put_field( + &mut hasher, + match object.kind { + SchemaObjectKind::Table => b"table", + SchemaObjectKind::Index => b"index", + SchemaObjectKind::View => b"view", + SchemaObjectKind::Trigger => b"trigger", + }, + ); + put_field(&mut hasher, object.name.as_bytes()); + put_field(&mut hasher, object.table_name.as_bytes()); + match &object.sql { + Some(sql) => { + hasher.update([1]); + put_field(&mut hasher, sql.as_bytes()); + } + None => hasher.update([0]), + } + hasher.update( + u64::try_from(object.columns.len()) + .unwrap_or(u64::MAX) + .to_be_bytes(), + ); + for column in &object.columns { + put_field(&mut hasher, column.name.as_bytes()); + put_field(&mut hasher, column.declared_type.as_bytes()); + hasher.update([u8::from(column.nullable), u8::from(column.primary_key)]); + } + } + let digest = hasher.finalize(); + let mut label = String::with_capacity(71); + label.push_str("sha256:"); + for byte in digest.as_slice() { + use std::fmt::Write as _; + write!(&mut label, "{byte:02x}").expect("writing to a string cannot fail"); + } + label +} + +fn put_field(hasher: &mut Sha256, bytes: &[u8]) { + hasher.update(u64::try_from(bytes.len()).unwrap_or(u64::MAX).to_be_bytes()); + hasher.update(bytes); +} diff --git a/crates/registry-platform-sqlite/src/statement.rs b/crates/registry-platform-sqlite/src/statement.rs new file mode 100644 index 000000000..0adaf8de4 --- /dev/null +++ b/crates/registry-platform-sqlite/src/statement.rs @@ -0,0 +1,1169 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::ffi::c_int; +use std::path::Path; +use std::sync::atomic::{AtomicU8, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use rusqlite::hooks::{AuthAction, AuthContext, Authorization}; +use rusqlite::limits::Limit; +use rusqlite::types::ValueRef; +use rusqlite::{Connection, ErrorCode, OpenFlags, Row}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest as _, Sha256}; +use tokio::sync::Semaphore; + +use crate::schema::{collect_schema_with_installed_budget, InspectionLimits}; +use crate::{CapturedSnapshot, ErrorKind, LiveDatabaseFile, SqliteError, TextLocation}; + +const PROGRESS_STEP_INTERVAL: u64 = 1_000; +const MAXIMUM_ENGINE_VALUE_BYTES: i32 = 8 * 1_024 * 1_024; +const MAXIMUM_CONCURRENCY: usize = 1_024; +/// SQL functions the authorizer refuses by name. +/// +/// The authorizer sees a function's name but not its arguments, so the whole +/// clock family is denied rather than only its `now` forms. This list is closed +/// against the SQLite amalgamation pinned by the workspace lockfile and must be +/// reviewed whenever that dependency changes. +const DENIED_FUNCTIONS: &[&str] = &[ + "changes", + "current_date", + "current_time", + "current_timestamp", + "date", + "datetime", + "julianday", + "last_insert_rowid", + "load_extension", + "random", + "randomblob", + "sqlite_offset", + "strftime", + "time", + "timediff", + "total_changes", + "unixepoch", +]; + +#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ColumnType { + String, + Integer, + Number, + Boolean, +} + +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct ColumnContract { + pub name: String, + pub value_type: ColumnType, +} + +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct ParameterContract { + pub name: String, + /// Required parameters must occur in the statement. Optional parameters + /// are permitted when present and may be supplied as harmless extra values. + pub required: bool, +} + +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct StatementLimits { + pub maximum_rows: u64, + pub maximum_cell_bytes: usize, + pub maximum_response_bytes: usize, + pub maximum_statement_steps: u64, + pub timeout: Duration, + pub concurrency: usize, +} + +impl StatementLimits { + fn validate(&self) -> Result<(), SqliteError> { + if self.maximum_rows == 0 + || self.maximum_cell_bytes == 0 + || self.maximum_response_bytes == 0 + || self.maximum_statement_steps == 0 + || self.timeout.is_zero() + || self.concurrency == 0 + || self.concurrency > MAXIMUM_CONCURRENCY + || self.maximum_cell_bytes + > usize::try_from(MAXIMUM_ENGINE_VALUE_BYTES).unwrap_or(usize::MAX) + || Instant::now().checked_add(self.timeout).is_none() + { + return Err(SqliteError::new(ErrorKind::InvalidPlan)); + } + Ok(()) + } +} + +/// Expected schema identity and the bounds used while re-verifying it. +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct SchemaBinding { + pub expected_fingerprint: String, + pub maximum_objects: usize, + pub maximum_sql_bytes: usize, +} + +impl SchemaBinding { + fn limits(&self, statement: &StatementLimits) -> InspectionLimits { + InspectionLimits { + maximum_objects: self.maximum_objects, + maximum_sql_bytes: self.maximum_sql_bytes, + maximum_statement_steps: statement.maximum_statement_steps, + timeout: statement.timeout, + } + } + + fn validate(&self, statement: &StatementLimits) -> Result<(), SqliteError> { + if self.maximum_objects == 0 + || self.maximum_sql_bytes == 0 + || !valid_sha256_label(&self.expected_fingerprint) + { + return Err(SqliteError::new(ErrorKind::InvalidPlan)); + } + self.limits(statement).validate() + } +} + +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct StatementContract { + pub sql: String, + pub columns: Vec, + pub parameters: Vec, + pub limits: StatementLimits, + /// Required for live databases and optional for immutable snapshots. + pub schema: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum Value { + Null, + String(String), + Integer(i64), + Number(f64), + Boolean(bool), +} + +pub type ResultRow = BTreeMap; + +#[derive(Debug, Clone, PartialEq)] +pub struct ResultSet { + pub rows: Vec, + pub provenance: ExecutionProvenance, +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum DatabaseProfileKind { + Snapshot, + LiveReadOnly, +} + +/// Source facts established for the exact transaction that returned the rows. +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExecutionProvenance { + pub profile: DatabaseProfileKind, + pub source_revision: Option, + pub schema_fingerprint: Option, + pub statement_digest: String, +} + +#[derive(Debug, Clone)] +pub enum DatabaseProfile { + Snapshot(CapturedSnapshot), + LiveReadOnly(LiveDatabaseFile), +} + +impl DatabaseProfile { + fn path(&self) -> &Path { + match self { + Self::Snapshot(value) => value.path(), + Self::LiveReadOnly(value) => value.path(), + } + } + fn immutable(&self) -> bool { + matches!(self, Self::Snapshot(_)) + } + fn kind(&self) -> DatabaseProfileKind { + match self { + Self::Snapshot(_) => DatabaseProfileKind::Snapshot, + Self::LiveReadOnly(_) => DatabaseProfileKind::LiveReadOnly, + } + } + fn source_revision(&self) -> Option { + match self { + Self::Snapshot(value) => Some(value.digest().to_owned()), + Self::LiveReadOnly(_) => None, + } + } + fn confirm(&self) -> Result<(), SqliteError> { + match self { + Self::Snapshot(value) => value.confirm_still_bound(), + Self::LiveReadOnly(value) => value.confirm_still_bound(), + } + } +} + +#[derive(Debug, Clone)] +struct BoundParameter { + index: usize, + name: String, +} + +#[derive(Debug)] +struct CompiledPlan { + sql: String, + columns: Vec, + parameters: Vec, + limits: StatementLimits, + schema: Option, + statement_digest: String, +} + +/// One compiled statement and one read-only connection pool. +pub struct ReadOnlyStatement { + profile: DatabaseProfile, + plan: Arc, + connections: Arc>>, + concurrency: Arc, +} + +impl ReadOnlyStatement { + pub fn open( + profile: DatabaseProfile, + contract: StatementContract, + ) -> Result { + contract.limits.validate()?; + if let Some(schema) = &contract.schema { + schema.validate(&contract.limits)?; + } + if matches!(profile, DatabaseProfile::LiveReadOnly(_)) && contract.schema.is_none() { + return Err(SqliteError::new(ErrorKind::InvalidPlan)); + } + if contract.columns.is_empty() { + return Err(SqliteError::new(ErrorKind::InvalidPlan)); + } + profile.confirm()?; + let connections = open_connection_pool(&profile, contract.limits.concurrency)?; + let first = connections + .first() + .ok_or_else(|| SqliteError::new(ErrorKind::InvalidPlan))?; + let parameters = verify_statement(first, &contract)?; + verify_schema_at_open(first, contract.schema.as_ref(), &contract.limits)?; + profile.confirm()?; + confirm_connection_pool_still_bound(&connections)?; + let permits = contract.limits.concurrency; + let statement_digest = statement_digest(&contract.sql); + Ok(Self { + profile, + plan: Arc::new(CompiledPlan { + sql: contract.sql, + columns: contract.columns, + parameters, + limits: contract.limits, + schema: contract.schema, + statement_digest, + }), + connections: Arc::new(Mutex::new(connections)), + concurrency: Arc::new(Semaphore::new(permits)), + }) + } + + /// Execute with one absolute queue-and-engine deadline. + pub async fn execute( + &self, + values: &BTreeMap, + ) -> Result { + self.profile.confirm()?; + let bindings = bind_values(&self.plan.parameters, values)?; + let deadline = deadline(self.plan.limits.timeout)?; + let async_deadline = tokio::time::Instant::from_std(deadline); + let permit = tokio::time::timeout_at( + async_deadline, + Arc::clone(&self.concurrency).acquire_owned(), + ) + .await + .map_err(|_| SqliteError::new(ErrorKind::Timeout))? + .map_err(|_| SqliteError::new(ErrorKind::Concurrency))?; + let connection = self + .connections + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .pop() + .ok_or_else(|| SqliteError::new(ErrorKind::WorkerUnavailable))?; + let plan = Arc::clone(&self.plan); + let pool = Arc::clone(&self.connections); + let execution = tokio::task::spawn_blocking(move || { + let result = confirm_connection_still_bound(&connection) + .and_then(|()| run_statement(&connection, &plan, &bindings, deadline)) + .and_then(|result| confirm_connection_still_bound(&connection).map(|()| result)); + pool.lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push(connection); + drop(permit); + result + }); + let (rows, schema_fingerprint) = tokio::time::timeout_at(async_deadline, execution) + .await + .map_err(|_| SqliteError::new(ErrorKind::Timeout))? + .map_err(|_| SqliteError::new(ErrorKind::WorkerUnavailable))??; + self.profile.confirm()?; + Ok(ResultSet { + rows, + provenance: self.provenance(schema_fingerprint), + }) + } + + /// Startup-only synchronous execution, under the same engine limits. + pub fn execute_at_open( + &self, + values: &BTreeMap, + ) -> Result { + self.profile.confirm()?; + let bindings = bind_values(&self.plan.parameters, values)?; + let deadline = deadline(self.plan.limits.timeout)?; + let connection = self + .connections + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .pop() + .ok_or_else(|| SqliteError::new(ErrorKind::WorkerUnavailable))?; + let outcome = confirm_connection_still_bound(&connection) + .and_then(|()| run_statement(&connection, &self.plan, &bindings, deadline)) + .and_then(|result| confirm_connection_still_bound(&connection).map(|()| result)); + self.connections + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push(connection); + self.profile.confirm()?; + let (rows, schema_fingerprint) = outcome?; + Ok(ResultSet { + rows, + provenance: self.provenance(schema_fingerprint), + }) + } + + #[must_use] + pub fn statement_digest(&self) -> &str { + &self.plan.statement_digest + } + + fn provenance(&self, schema_fingerprint: Option) -> ExecutionProvenance { + ExecutionProvenance { + profile: self.profile.kind(), + source_revision: self.profile.source_revision(), + schema_fingerprint, + statement_digest: self.plan.statement_digest.clone(), + } + } + + /// Hold every admission permit for a fixture that proves queue deadlines. + #[cfg(feature = "fixture")] + #[doc(hidden)] + pub async fn hold_all_permits_for_test( + &self, + ) -> Result { + let permits = u32::try_from(self.plan.limits.concurrency) + .map_err(|_| SqliteError::new(ErrorKind::InvalidPlan))?; + Arc::clone(&self.concurrency) + .acquire_many_owned(permits) + .await + .map_err(|_| SqliteError::new(ErrorKind::Concurrency)) + } +} + +pub fn check_statement_offline(contract: &StatementContract) -> Result<(), SqliteError> { + contract.limits.validate()?; + if let Some(schema) = &contract.schema { + schema.validate(&contract.limits)?; + } + if contains_positional_parameter(&contract.sql) { + return Err(SqliteError::new(ErrorKind::UndeclaredParameter)); + } + let connection = + Connection::open_in_memory().map_err(|_| SqliteError::new(ErrorKind::ExecutionFailed))?; + install_authorizer(&connection).map_err(|_| SqliteError::new(ErrorKind::ExecutionFailed))?; + match connection.prepare(&contract.sql).map(|_| ()) { + Ok(()) => Ok(()), + Err(error) => { + let classified = classify_prepare(&error, &contract.sql); + if matches!( + classified.kind(), + ErrorKind::UnknownTable | ErrorKind::UnknownColumn + ) { + Ok(()) + } else { + Err(classified) + } + } + } +} + +fn open_connection(profile: &DatabaseProfile) -> Result { + let uri = database_uri(profile.path(), profile.immutable()) + .ok_or_else(|| SqliteError::new(ErrorKind::DatabaseUnavailable))?; + let flags = OpenFlags::SQLITE_OPEN_READ_ONLY + | OpenFlags::SQLITE_OPEN_URI + | OpenFlags::SQLITE_OPEN_NO_MUTEX; + let connection = Connection::open_with_flags(uri, flags) + .map_err(|_| SqliteError::new(ErrorKind::DatabaseUnavailable))?; + connection + .set_limit(Limit::SQLITE_LIMIT_LENGTH, MAXIMUM_ENGINE_VALUE_BYTES) + .map_err(|_| SqliteError::new(ErrorKind::DatabaseUnavailable))?; + install_authorizer(&connection) + .map_err(|_| SqliteError::new(ErrorKind::DatabaseUnavailable))?; + confirm_connection_still_bound(&connection)?; + Ok(connection) +} + +fn open_connection_pool( + profile: &DatabaseProfile, + concurrency: usize, +) -> Result, SqliteError> { + let mut connections = Vec::with_capacity(concurrency); + for _ in 0..concurrency { + connections.push(open_connection(profile)?); + } + Ok(connections) +} + +fn confirm_connection_pool_still_bound(connections: &[Connection]) -> Result<(), SqliteError> { + for connection in connections { + confirm_connection_still_bound(connection)?; + } + Ok(()) +} + +/// Ask the active SQLite VFS whether the actual `main` handle has moved away +/// from the pathname that opened it. This detects a swap-and-restore attack +/// that pathname metadata checks alone cannot see. +#[cfg(unix)] +#[allow(unsafe_code)] +pub(crate) fn confirm_connection_still_bound(connection: &Connection) -> Result<(), SqliteError> { + let mut moved = 0_i32; + // SAFETY: `connection.handle()` is valid for the shared borrow, `main` is + // NUL-terminated, and SQLite writes one `c_int` to the supplied pointer for + // `SQLITE_FCNTL_HAS_MOVED` without retaining it. + let result = unsafe { + rusqlite::ffi::sqlite3_file_control( + connection.handle(), + c"main".as_ptr(), + rusqlite::ffi::SQLITE_FCNTL_HAS_MOVED, + std::ptr::addr_of_mut!(moved).cast(), + ) + }; + if result != rusqlite::ffi::SQLITE_OK || moved != 0 { + return Err(SqliteError::new(ErrorKind::DatabaseReplaced)); + } + Ok(()) +} + +#[cfg(not(unix))] +pub(crate) fn confirm_connection_still_bound(_connection: &Connection) -> Result<(), SqliteError> { + Err(SqliteError::new(ErrorKind::DatabaseUnavailable)) +} + +pub(crate) fn database_uri(path: &Path, immutable: bool) -> Option { + let text = path.to_str()?; + let mut uri = String::from("file:"); + for byte in text.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' => { + uri.push(char::from(byte)) + } + other => uri.push_str(&format!("%{other:02X}")), + } + } + uri.push_str("?mode=ro"); + if immutable { + uri.push_str("&immutable=1"); + } + Some(uri) +} + +pub(crate) fn install_authorizer(connection: &Connection) -> rusqlite::Result<()> { + connection.authorizer(Some(|context: AuthContext<'_>| authorize(&context.action))) +} + +fn authorize(action: &AuthAction<'_>) -> Authorization { + match action { + AuthAction::Read { .. } | AuthAction::Select | AuthAction::Recursive => { + Authorization::Allow + } + AuthAction::Function { function_name, .. } => { + if DENIED_FUNCTIONS + .iter() + .any(|denied| function_name.eq_ignore_ascii_case(denied)) + { + Authorization::Deny + } else { + Authorization::Allow + } + } + AuthAction::Attach { .. } + | AuthAction::Detach { .. } + | AuthAction::Pragma { .. } + | AuthAction::Transaction { .. } + | AuthAction::Savepoint { .. } + | AuthAction::CreateIndex { .. } + | AuthAction::CreateTable { .. } + | AuthAction::CreateTempIndex { .. } + | AuthAction::CreateTempTable { .. } + | AuthAction::CreateTempTrigger { .. } + | AuthAction::CreateTempView { .. } + | AuthAction::CreateTrigger { .. } + | AuthAction::CreateView { .. } + | AuthAction::Delete { .. } + | AuthAction::DropIndex { .. } + | AuthAction::DropTable { .. } + | AuthAction::DropTempIndex { .. } + | AuthAction::DropTempTable { .. } + | AuthAction::DropTempTrigger { .. } + | AuthAction::DropTempView { .. } + | AuthAction::DropTrigger { .. } + | AuthAction::DropView { .. } + | AuthAction::Insert { .. } + | AuthAction::AlterTable { .. } + | AuthAction::Reindex { .. } + | AuthAction::Analyze { .. } + | AuthAction::CreateVtable { .. } + | AuthAction::DropVtable { .. } + | AuthAction::Update { .. } => Authorization::Deny, + _ => Authorization::Deny, + } +} + +fn verify_statement( + connection: &Connection, + contract: &StatementContract, +) -> Result, SqliteError> { + if contains_positional_parameter(&contract.sql) { + return Err(SqliteError::new(ErrorKind::UndeclaredParameter)); + } + let statement = connection + .prepare(&contract.sql) + .map_err(|error| classify_prepare(&error, &contract.sql))?; + if statement.column_count() != contract.columns.len() { + return Err(SqliteError::new(ErrorKind::ColumnMismatch)); + } + for (index, declared) in contract.columns.iter().enumerate() { + if statement + .column_name(index) + .map_err(|_| SqliteError::new(ErrorKind::ColumnMismatch))? + != declared.name + { + return Err(SqliteError::new(ErrorKind::ColumnMismatch)); + } + } + let declared: BTreeSet<&str> = contract + .parameters + .iter() + .map(|value| value.name.as_str()) + .collect(); + if declared.len() != contract.parameters.len() { + return Err(SqliteError::new(ErrorKind::InvalidPlan)); + } + let mut parameters = Vec::new(); + for index in 1..=statement.parameter_count() { + let name = statement + .parameter_name(index) + .and_then(bare_parameter_name) + .ok_or_else(|| SqliteError::new(ErrorKind::UndeclaredParameter))?; + if !declared.contains(name) { + return Err(SqliteError::new(ErrorKind::UndeclaredParameter)); + } + parameters.push(BoundParameter { + index, + name: name.to_owned(), + }); + } + for declared_parameter in &contract.parameters { + if declared_parameter.required + && !parameters + .iter() + .any(|value| value.name == declared_parameter.name) + { + return Err(SqliteError::new(ErrorKind::UnusedBinding)); + } + } + Ok(parameters) +} + +fn bind_values( + parameters: &[BoundParameter], + values: &BTreeMap, +) -> Result, SqliteError> { + parameters + .iter() + .map(|parameter| { + values + .get(¶meter.name) + .cloned() + .map(|value| (parameter.index, value)) + .ok_or_else(|| SqliteError::new(ErrorKind::MissingParameter)) + }) + .collect() +} + +fn bare_parameter_name(name: &str) -> Option<&str> { + let mut chars = name.chars(); + match chars.next()? { + ':' | '@' | '$' => Some(chars.as_str()), + _ => None, + } +} + +fn contains_positional_parameter(sql: &str) -> bool { + #[derive(Clone, Copy)] + enum State { + Sql, + Quote(u8), + Bracket, + LineComment, + BlockComment, + } + let bytes = sql.as_bytes(); + let mut state = State::Sql; + let mut index = 0; + while index < bytes.len() { + let byte = bytes[index]; + let next = bytes.get(index + 1).copied(); + match state { + State::Sql => match (byte, next) { + (b'?', _) => return true, + (b'\'', _) | (b'"', _) | (b'`', _) => state = State::Quote(byte), + (b'[', _) => state = State::Bracket, + (b'-', Some(b'-')) => { + state = State::LineComment; + index += 1; + } + (b'/', Some(b'*')) => { + state = State::BlockComment; + index += 1; + } + _ => {} + }, + State::Quote(quote) if byte == quote => { + if next == Some(quote) { + index += 1; + } else { + state = State::Sql; + } + } + State::Quote(_) => {} + State::Bracket if byte == b']' => state = State::Sql, + State::Bracket => {} + State::LineComment if matches!(byte, b'\n' | b'\r') => state = State::Sql, + State::LineComment => {} + State::BlockComment if byte == b'*' && next == Some(b'/') => { + state = State::Sql; + index += 1; + } + State::BlockComment => {} + } + index += 1; + } + false +} + +const BUDGET_WITHIN: u8 = 0; +const BUDGET_STEPS: u8 = 1; +const BUDGET_TIME: u8 = 2; + +fn install_progress_handler( + connection: &Connection, + steps: u64, + deadline: Instant, +) -> Result, SqliteError> { + let outcome = Arc::new(AtomicU8::new(BUDGET_WITHIN)); + let observed = Arc::clone(&outcome); + let interval = steps.clamp(1, PROGRESS_STEP_INTERVAL); + let mut consumed = 0_u64; + connection + .progress_handler( + c_int::try_from(interval).unwrap_or(c_int::MAX), + Some(move || { + consumed = consumed.saturating_add(interval); + if consumed >= steps { + observed.store(BUDGET_STEPS, Ordering::Relaxed); + true + } else if Instant::now() >= deadline { + observed.store(BUDGET_TIME, Ordering::Relaxed); + true + } else { + false + } + }), + ) + .map_err(|_| SqliteError::new(ErrorKind::ExecutionFailed))?; + Ok(outcome) +} + +fn run_statement( + connection: &Connection, + plan: &CompiledPlan, + bindings: &[(usize, Value)], + deadline: Instant, +) -> Result<(Vec, Option), SqliteError> { + begin_read_transaction(connection)?; + let outcome = run_statement_in_transaction(connection, plan, bindings, deadline); + let closed = end_read_transaction(connection); + match (outcome, closed) { + (Ok(value), Ok(())) => Ok(value), + (Err(error), _) => Err(error), + (Ok(_), Err(error)) => Err(error), + } +} + +fn run_statement_in_transaction( + connection: &Connection, + plan: &CompiledPlan, + bindings: &[(usize, Value)], + deadline: Instant, +) -> Result<(Vec, Option), SqliteError> { + if Instant::now() >= deadline { + return Err(SqliteError::new(ErrorKind::TimeBudgetExceeded)); + } + let budget = + install_progress_handler(connection, plan.limits.maximum_statement_steps, deadline)?; + let schema_fingerprint = verify_schema_in_transaction(connection, plan, &budget)?; + let mut statement = connection + .prepare(&plan.sql) + .map_err(|error| classify_prepare(&error, &plan.sql))?; + for (index, value) in bindings { + let outcome = match value { + Value::Null => statement.raw_bind_parameter(*index, rusqlite::types::Null), + Value::String(value) => statement.raw_bind_parameter(*index, value), + Value::Integer(value) => statement.raw_bind_parameter(*index, value), + Value::Number(value) => statement.raw_bind_parameter(*index, value), + Value::Boolean(value) => statement.raw_bind_parameter(*index, i64::from(*value)), + }; + outcome.map_err(|_| SqliteError::new(ErrorKind::ExecutionFailed))?; + } + let mut rows = statement.raw_query(); + let mut collected = Vec::new(); + // Include the outer collection even when it is empty. This is a + // conservative serialization/allocation budget, not just cell payload. + let mut response_bytes = 0_usize; + charge_response(&mut response_bytes, 2, plan.limits.maximum_response_bytes)?; + loop { + let row = match rows.next() { + Ok(Some(row)) => row, + Ok(None) => break, + Err(error) => return Err(classify_step(&error, &budget)), + }; + if collected.len() as u64 >= plan.limits.maximum_rows { + return Err(SqliteError::new(ErrorKind::TooManyRows)); + } + charge_response( + &mut response_bytes, + if collected.is_empty() { 2 } else { 3 }, + plan.limits.maximum_response_bytes, + )?; + collected.push(read_row(row, plan, &mut response_bytes)?); + } + Ok((collected, schema_fingerprint)) +} + +fn begin_read_transaction(connection: &Connection) -> Result<(), SqliteError> { + connection + .authorizer(None::) -> Authorization>) + .map_err(|_| SqliteError::new(ErrorKind::ExecutionFailed))?; + let begun = connection.execute_batch("BEGIN DEFERRED"); + let authorized = install_authorizer(connection); + if begun.is_err() || authorized.is_err() { + return Err(SqliteError::new(ErrorKind::ExecutionFailed)); + } + Ok(()) +} + +fn end_read_transaction(connection: &Connection) -> Result<(), SqliteError> { + connection + .authorizer(None::) -> Authorization>) + .map_err(|_| SqliteError::new(ErrorKind::ExecutionFailed))?; + let rolled_back = connection.execute_batch("ROLLBACK"); + let authorized = install_authorizer(connection); + if rolled_back.is_err() || authorized.is_err() { + return Err(SqliteError::new(ErrorKind::ExecutionFailed)); + } + Ok(()) +} + +fn verify_schema_at_open( + connection: &Connection, + binding: Option<&SchemaBinding>, + limits: &StatementLimits, +) -> Result<(), SqliteError> { + let Some(binding) = binding else { + return Ok(()); + }; + begin_read_transaction(connection)?; + let deadline = deadline(limits.timeout)?; + let budget = install_progress_handler(connection, limits.maximum_statement_steps, deadline)?; + let outcome = schema_fingerprint_with_budget(connection, binding, limits, &budget); + let closed = end_read_transaction(connection); + match (outcome, closed) { + (Ok(_), Ok(())) => Ok(()), + (Err(error), _) => Err(error), + (Ok(_), Err(error)) => Err(error), + } +} + +fn verify_schema_in_transaction( + connection: &Connection, + plan: &CompiledPlan, + budget: &AtomicU8, +) -> Result, SqliteError> { + plan.schema + .as_ref() + .map(|binding| schema_fingerprint_with_budget(connection, binding, &plan.limits, budget)) + .transpose() +} + +fn schema_fingerprint_with_budget( + connection: &Connection, + binding: &SchemaBinding, + statement_limits: &StatementLimits, + budget: &AtomicU8, +) -> Result { + let limits = binding.limits(statement_limits); + // The reviewed-statement authorizer denies virtual-table actions used by + // `pragma_table_xinfo`. Remove it only around this fixed schema-only query. + // The connection remains engine read-only and inside the request's read + // transaction, and no caller SQL can run on this worker in the interval. + connection + .authorizer(None::) -> Authorization>) + .map_err(|_| SqliteError::new(ErrorKind::SchemaMalformed))?; + let collected = collect_schema_with_installed_budget(connection, &limits); + if install_authorizer(connection).is_err() { + return Err(SqliteError::new(ErrorKind::SchemaMalformed)); + } + let objects = collected.map_err(|error| match budget.load(Ordering::Relaxed) { + BUDGET_STEPS => SqliteError::new(ErrorKind::StepBudgetExceeded), + BUDGET_TIME => SqliteError::new(ErrorKind::TimeBudgetExceeded), + _ => error, + })?; + let observed = crate::schema::fingerprint_objects(&objects); + if observed != binding.expected_fingerprint { + return Err(SqliteError::new(ErrorKind::SchemaMismatch)); + } + Ok(observed) +} + +fn read_row( + row: &Row<'_>, + plan: &CompiledPlan, + response_bytes: &mut usize, +) -> Result { + let mut object = BTreeMap::new(); + for (index, column) in plan.columns.iter().enumerate() { + charge_response( + response_bytes, + usize::from(index > 0) + .saturating_add(json_string_bytes(column.name.as_bytes())) + .saturating_add(1), + plan.limits.maximum_response_bytes, + )?; + let raw = row + .get_ref(index) + .map_err(|_| SqliteError::new(ErrorKind::ExecutionFailed))?; + let (value, bytes) = read_value(raw, column.value_type, plan.limits.maximum_cell_bytes)?; + charge_response( + response_bytes, + serialized_value_bytes(&value).max(bytes), + plan.limits.maximum_response_bytes, + )?; + object.insert(column.name.clone(), value); + } + Ok(object) +} + +fn charge_response( + consumed: &mut usize, + additional: usize, + maximum: usize, +) -> Result<(), SqliteError> { + *consumed = consumed.saturating_add(additional); + if *consumed > maximum { + return Err(SqliteError::new(ErrorKind::ResponseTooLarge)); + } + Ok(()) +} + +fn serialized_value_bytes(value: &Value) -> usize { + match value { + Value::Null => 4, + Value::String(value) => json_string_bytes(value.as_bytes()), + // Conservative bounds avoid allocating temporary number strings while + // covering every i64 and finite f64 JSON spelling. + Value::Integer(_) | Value::Number(_) => 32, + Value::Boolean(_) => 5, + } +} + +fn json_string_bytes(bytes: &[u8]) -> usize { + bytes.iter().fold(2_usize, |total, byte| { + total.saturating_add(match byte { + b'"' | b'\\' | b'\x08' | b'\x09' | b'\x0a' | b'\x0c' | b'\x0d' => 2, + b'\x00'..=b'\x1f' => 6, + _ => 1, + }) + }) +} + +fn read_value( + value: ValueRef<'_>, + declared: ColumnType, + max: usize, +) -> Result<(Value, usize), SqliteError> { + match (value, declared) { + (ValueRef::Null, _) => Ok((Value::Null, 0)), + (ValueRef::Text(bytes), ColumnType::String) => { + if bytes.len() > max { + return Err(SqliteError::new(ErrorKind::CellTooLarge)); + } + let value = std::str::from_utf8(bytes) + .map_err(|_| SqliteError::new(ErrorKind::ValueTypeMismatch))?; + Ok((Value::String(value.to_owned()), bytes.len())) + } + (ValueRef::Integer(value), ColumnType::Integer) => Ok((Value::Integer(value), 8)), + // Preserve SQLite's integer JSON representation for a declared number, + // matching serde_json's distinction between `1` and `1.0`. + (ValueRef::Integer(value), ColumnType::Number) => Ok((Value::Integer(value), 8)), + (ValueRef::Real(value), ColumnType::Number) if value.is_finite() => { + Ok((Value::Number(value), 8)) + } + (ValueRef::Integer(0), ColumnType::Boolean) => Ok((Value::Boolean(false), 1)), + (ValueRef::Integer(1), ColumnType::Boolean) => Ok((Value::Boolean(true), 1)), + _ => Err(SqliteError::new(ErrorKind::ValueTypeMismatch)), + } +} + +fn classify_prepare(error: &rusqlite::Error, sql: &str) -> SqliteError { + match error { + rusqlite::Error::MultipleStatement => SqliteError::new(ErrorKind::MultipleStatements), + rusqlite::Error::SqliteFailure(failure, message) => { + SqliteError::new(classify_failure(failure.code, message.as_deref())) + } + rusqlite::Error::SqlInputError { + error, msg, offset, .. + } => { + let kind = classify_failure(error.code, Some(msg)); + if kind == ErrorKind::InvalidSql { + if let Ok(offset) = usize::try_from(*offset) { + return SqliteError::at(kind, text_location(sql, offset)); + } + } + SqliteError::new(kind) + } + _ => SqliteError::new(ErrorKind::InvalidSql), + } +} + +fn classify_failure(code: ErrorCode, message: Option<&str>) -> ErrorKind { + match code { + ErrorCode::AuthorizationForStatementDenied => ErrorKind::AuthorizerRefused, + ErrorCode::Unknown => match message { + Some(value) if value.starts_with("no such table") => ErrorKind::UnknownTable, + Some(value) if value.starts_with("no such column") => ErrorKind::UnknownColumn, + Some(value) if value.starts_with("not authorized") => ErrorKind::AuthorizerRefused, + _ => ErrorKind::InvalidSql, + }, + _ => ErrorKind::InvalidSql, + } +} + +fn classify_step(error: &rusqlite::Error, budget: &AtomicU8) -> SqliteError { + match budget.load(Ordering::Relaxed) { + BUDGET_STEPS => SqliteError::new(ErrorKind::StepBudgetExceeded), + BUDGET_TIME => SqliteError::new(ErrorKind::TimeBudgetExceeded), + _ => match error { + rusqlite::Error::SqliteFailure(failure, _) if failure.code == ErrorCode::TooBig => { + SqliteError::new(ErrorKind::CellTooLarge) + } + _ => SqliteError::new(ErrorKind::ExecutionFailed), + }, + } +} + +fn text_location(text: &str, offset: usize) -> TextLocation { + let mut line = 1; + let mut column = 1; + for (index, character) in text.char_indices() { + if index >= offset { + break; + } + if character == '\n' { + line += 1; + column = 1; + } else { + column += 1; + } + } + TextLocation { line, column } +} + +fn deadline(timeout: Duration) -> Result { + Instant::now() + .checked_add(timeout) + .ok_or_else(|| SqliteError::new(ErrorKind::InvalidPlan)) +} + +fn valid_sha256_label(value: &str) -> bool { + value.len() == 71 + && value.starts_with("sha256:") + && value.as_bytes()[7..] + .iter() + .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) +} + +fn statement_digest(sql: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(b"registry-platform-sqlite-statement-v1\0"); + hasher.update(u64::try_from(sql.len()).unwrap_or(u64::MAX).to_be_bytes()); + hasher.update(sql.as_bytes()); + let digest = hasher.finalize(); + let mut label = String::with_capacity(71); + label.push_str("sha256:"); + for byte in digest.as_slice() { + use std::fmt::Write as _; + write!(&mut label, "{byte:02x}").expect("writing to a string cannot fail"); + } + label +} + +#[cfg(feature = "fixture")] +pub fn materialize_fixture(target: &Path, seed_sql: &str) -> Result<(), SqliteError> { + let connection = + Connection::open(target).map_err(|_| SqliteError::new(ErrorKind::DatabaseUnavailable))?; + connection + .execute_batch(seed_sql) + .map_err(|_| SqliteError::new(ErrorKind::InvalidSql))?; + connection + .close() + .map_err(|_| SqliteError::new(ErrorKind::DatabaseUnavailable))?; + let mut permissions = std::fs::metadata(target) + .map_err(|_| SqliteError::new(ErrorKind::DatabaseUnavailable))? + .permissions(); + permissions.set_readonly(true); + std::fs::set_permissions(target, permissions) + .map_err(|_| SqliteError::new(ErrorKind::DatabaseUnavailable)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(unix)] + fn database(path: &Path, marker: &str) { + use std::os::unix::fs::PermissionsExt as _; + + let connection = Connection::open(path).expect("database opens"); + connection + .execute_batch(&format!( + "CREATE TABLE records (id TEXT); INSERT INTO records VALUES ('{marker}');" + )) + .expect("database materializes"); + connection.close().expect("database closes"); + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o400)) + .expect("database becomes read-only"); + } + + #[cfg(unix)] + #[test] + fn actual_snapshot_and_live_pool_handles_refuse_swap_and_restore() { + for live in [false, true] { + let temporary = tempfile::tempdir().expect("temporary root"); + let path = temporary.path().join("source.sqlite"); + database(&path, "governed"); + let profile = if live { + DatabaseProfile::LiveReadOnly( + LiveDatabaseFile::bind(&path).expect("live source binds"), + ) + } else { + DatabaseProfile::Snapshot( + CapturedSnapshot::capture(&path).expect("snapshot captures"), + ) + }; + profile.confirm().expect("governed path starts bound"); + + let governed = temporary.path().join("governed.sqlite"); + std::fs::rename(&path, &governed).expect("governed source moves"); + database(&path, "substitute"); + + // This is the exact old race window: the pre-open pathname check + // has completed, and every pool member now opens the substitute. + let connections = + open_connection_pool(&profile, 3).expect("substitute pool opens by pathname"); + + let substitute = temporary.path().join("substitute.sqlite"); + std::fs::rename(&path, substitute).expect("substitute moves away"); + std::fs::rename(governed, &path).expect("governed source is restored"); + if live { + profile + .confirm() + .expect("live pathname-only post-check sees the restored inode"); + } else { + assert_eq!( + profile + .confirm() + .expect_err("snapshot metadata also detects the rename") + .kind(), + ErrorKind::DatabaseReplaced + ); + } + + for connection in &connections { + assert_eq!( + confirm_connection_still_bound(connection) + .expect_err("actual substitute handle must be refused") + .kind(), + ErrorKind::DatabaseReplaced + ); + } + } + } + + #[test] + fn positional_parameter_scan_ignores_literals_identifiers_and_comments() { + assert!(!contains_positional_parameter( + "SELECT '?' AS \"?\", `?`, [?] -- ?1\n/* ?2 */" + )); + assert!(contains_positional_parameter("SELECT :record, ?1")); + assert!(contains_positional_parameter("SELECT ?")); + } + + #[test] + fn an_engine_failure_while_stepping_is_not_reported_as_invalid_sql() { + let error = rusqlite::Error::SqliteFailure( + rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CORRUPT), + Some("database disk image is malformed around protected-value".to_owned()), + ); + let budget = AtomicU8::new(BUDGET_WITHIN); + let classified = classify_step(&error, &budget); + assert_eq!(classified.kind(), ErrorKind::ExecutionFailed); + assert!(!classified.to_string().contains("protected-value")); + } + + #[test] + fn an_offset_at_or_past_the_end_of_the_text_still_has_a_position() { + let text = "SELECT id\nFROM person"; + for offset in [text.len(), text.len() + 1, usize::MAX] { + assert_eq!( + text_location(text, offset), + TextLocation { + line: 2, + column: 12, + } + ); + } + assert_eq!(text_location("", 0), TextLocation { line: 1, column: 1 }); + assert_eq!( + text_location("SELECT id\n", 10), + TextLocation { line: 2, column: 1 }, + ); + } +} diff --git a/crates/registry-platform-sqlite/tests/kernel.rs b/crates/registry-platform-sqlite/tests/kernel.rs new file mode 100644 index 000000000..fcefb5c51 --- /dev/null +++ b/crates/registry-platform-sqlite/tests/kernel.rs @@ -0,0 +1,462 @@ +use std::collections::BTreeMap; +use std::fs; +use std::time::Duration; + +use registry_platform_sqlite::{ + inspect_schema, CapturedSnapshot, ColumnContract, ColumnType, DatabaseProfile, + DatabaseProfileKind, ErrorKind, InspectionLimits, LiveDatabaseFile, ParameterContract, + ReadOnlyStatement, SchemaBinding, StatementContract, StatementLimits, Value, +}; +use rusqlite::Connection; +use tempfile::TempDir; + +fn database(directory: &TempDir) -> std::path::PathBuf { + let path = directory.path().join("source.sqlite"); + let connection = Connection::open(&path).unwrap(); + connection.execute_batch("CREATE TABLE records (id TEXT, active INTEGER); INSERT INTO records VALUES ('one', 1), ('two', 0);").unwrap(); + connection.close().unwrap(); + let mut permissions = fs::metadata(&path).unwrap().permissions(); + permissions.set_readonly(true); + fs::set_permissions(&path, permissions).unwrap(); + path +} + +#[cfg(unix)] +fn make_writable(path: &std::path::Path) { + use std::os::unix::fs::PermissionsExt as _; + fs::set_permissions(path, fs::Permissions::from_mode(0o600)).unwrap(); +} + +#[cfg(not(unix))] +fn make_writable(path: &std::path::Path) { + let mut permissions = fs::metadata(path).unwrap().permissions(); + permissions.set_readonly(false); + fs::set_permissions(path, permissions).unwrap(); +} + +fn contract(sql: &str) -> StatementContract { + StatementContract { + sql: sql.to_owned(), + columns: vec![ColumnContract { + name: "id".to_owned(), + value_type: ColumnType::String, + }], + parameters: vec![ParameterContract { + name: "active".to_owned(), + required: true, + }], + limits: StatementLimits { + maximum_rows: 2, + maximum_cell_bytes: 32, + maximum_response_bytes: 64, + maximum_statement_steps: 100_000, + timeout: Duration::from_secs(1), + concurrency: 1, + }, + schema: None, + } +} + +#[tokio::test] +async fn a_snapshot_is_digest_bound_and_read_immutably() { + let directory = TempDir::new().unwrap(); + let path = database(&directory); + let snapshot = CapturedSnapshot::capture(&path).unwrap(); + assert!(snapshot.digest().starts_with("sha256:")); + let expected_revision = snapshot.digest().to_owned(); + let statement = ReadOnlyStatement::open( + DatabaseProfile::Snapshot(snapshot), + contract("SELECT id FROM records WHERE active = :active ORDER BY id"), + ) + .unwrap(); + let rows = statement + .execute(&BTreeMap::from([( + "active".to_owned(), + Value::Boolean(true), + )])) + .await + .unwrap(); + assert_eq!(rows.rows[0]["id"], Value::String("one".to_owned())); + assert_eq!(rows.provenance.profile, DatabaseProfileKind::Snapshot); + assert_eq!( + rows.provenance.source_revision.as_deref(), + Some(expected_revision.as_str()) + ); + assert!(rows.provenance.statement_digest.starts_with("sha256:")); +} + +#[test] +fn every_mutating_or_connection_widening_action_is_refused() { + let directory = TempDir::new().unwrap(); + let path = database(&directory); + let snapshot = CapturedSnapshot::capture(&path).unwrap(); + for sql in [ + "DELETE FROM records RETURNING id", + "SELECT id FROM records; ATTACH ':memory:' AS extra", + "PRAGMA table_info(records)", + ] { + let error = + ReadOnlyStatement::open(DatabaseProfile::Snapshot(snapshot.clone()), contract(sql)) + .err() + .unwrap(); + assert!(matches!( + error.kind(), + ErrorKind::AuthorizerRefused | ErrorKind::MultipleStatements + )); + } +} + +#[test] +fn clock_and_random_functions_are_refused() { + let directory = TempDir::new().unwrap(); + let path = database(&directory); + let snapshot = CapturedSnapshot::capture(&path).unwrap(); + for sql in [ + "SELECT random() AS id FROM records", + "SELECT datetime('now') AS id FROM records", + ] { + assert_eq!( + ReadOnlyStatement::open(DatabaseProfile::Snapshot(snapshot.clone()), contract(sql)) + .err() + .unwrap() + .kind(), + ErrorKind::AuthorizerRefused + ); + } +} + +#[tokio::test] +async fn row_cell_and_response_bounds_are_enforced() { + let directory = TempDir::new().unwrap(); + let path = database(&directory); + let snapshot = CapturedSnapshot::capture(&path).unwrap(); + let mut bounded = contract("SELECT id FROM records WHERE active >= :active ORDER BY id"); + bounded.limits.maximum_rows = 1; + let statement = + ReadOnlyStatement::open(DatabaseProfile::Snapshot(snapshot.clone()), bounded).unwrap(); + assert_eq!( + statement + .execute(&BTreeMap::from([("active".to_owned(), Value::Integer(0))])) + .await + .unwrap_err() + .kind(), + ErrorKind::TooManyRows + ); + let mut bounded = contract("SELECT id FROM records WHERE active >= :active ORDER BY id"); + bounded.limits.maximum_cell_bytes = 2; + let statement = + ReadOnlyStatement::open(DatabaseProfile::Snapshot(snapshot.clone()), bounded).unwrap(); + assert_eq!( + statement + .execute(&BTreeMap::from([("active".to_owned(), Value::Integer(0))])) + .await + .unwrap_err() + .kind(), + ErrorKind::CellTooLarge + ); + let mut bounded = contract("SELECT id FROM records WHERE active >= :active ORDER BY id"); + bounded.limits.maximum_response_bytes = 4; + let statement = ReadOnlyStatement::open(DatabaseProfile::Snapshot(snapshot), bounded).unwrap(); + assert_eq!( + statement + .execute(&BTreeMap::from([("active".to_owned(), Value::Integer(0))])) + .await + .unwrap_err() + .kind(), + ErrorKind::ResponseTooLarge + ); +} + +#[tokio::test] +async fn null_and_column_structure_count_toward_the_response_bound() { + let directory = TempDir::new().unwrap(); + let path = database(&directory); + let snapshot = CapturedSnapshot::capture(&path).unwrap(); + let mut bounded = contract( + "WITH RECURSIVE rows(n) AS (\ + SELECT 1 UNION ALL SELECT n + 1 FROM rows WHERE n < 20\ + ) SELECT NULL AS a_deliberately_long_structural_column_name FROM rows \ + WHERE :active = :active", + ); + bounded.columns[0] = ColumnContract { + name: "a_deliberately_long_structural_column_name".to_owned(), + value_type: ColumnType::String, + }; + bounded.limits.maximum_rows = 100; + bounded.limits.maximum_response_bytes = 128; + let statement = + ReadOnlyStatement::open(DatabaseProfile::Snapshot(snapshot.clone()), bounded).unwrap(); + assert_eq!( + statement + .execute(&BTreeMap::from([("active".to_owned(), Value::Integer(1))])) + .await + .unwrap_err() + .kind(), + ErrorKind::ResponseTooLarge + ); + + let mut empty = contract("SELECT id FROM records WHERE active > :active"); + empty.limits.maximum_response_bytes = 1; + let statement = ReadOnlyStatement::open(DatabaseProfile::Snapshot(snapshot), empty).unwrap(); + assert_eq!( + statement + .execute(&BTreeMap::from([( + "active".to_owned(), + Value::Integer(i64::MAX), + )])) + .await + .unwrap_err() + .kind(), + ErrorKind::ResponseTooLarge + ); +} + +#[test] +fn snapshot_sidecars_and_path_replacement_fail_closed() { + let directory = TempDir::new().unwrap(); + let path = database(&directory); + fs::write(format!("{}-wal", path.display()), b"sidecar").unwrap(); + assert_eq!( + CapturedSnapshot::capture(&path).unwrap_err().kind(), + ErrorKind::UncheckpointedSidecar + ); + fs::remove_file(format!("{}-wal", path.display())).unwrap(); + let snapshot = CapturedSnapshot::capture(&path).unwrap(); + fs::write(format!("{}-journal", path.display()), b"sidecar").unwrap(); + assert_eq!( + snapshot.confirm_still_bound().unwrap_err().kind(), + ErrorKind::UncheckpointedSidecar + ); + fs::remove_file(format!("{}-journal", path.display())).unwrap(); + fs::rename(&path, directory.path().join("old.sqlite")).unwrap(); + let replacement = database(&directory); + assert_eq!(replacement, path); + assert_eq!( + snapshot.confirm_still_bound().unwrap_err().kind(), + ErrorKind::DatabaseReplaced + ); +} + +#[test] +fn snapshot_readiness_rehashes_the_exact_captured_bytes() { + let directory = TempDir::new().unwrap(); + let path = database(&directory); + let snapshot = CapturedSnapshot::capture(&path).unwrap(); + snapshot.verify_unchanged().unwrap(); + + make_writable(&path); + let connection = Connection::open(&path).unwrap(); + connection + .execute("UPDATE records SET active = 0 WHERE id = 'one'", []) + .unwrap(); + connection.close().unwrap(); + let mut permissions = fs::metadata(&path).unwrap().permissions(); + permissions.set_readonly(true); + fs::set_permissions(&path, permissions).unwrap(); + + let error = snapshot.verify_unchanged().unwrap_err(); + assert!(matches!( + error.kind(), + ErrorKind::DatabaseChanged | ErrorKind::DatabaseReplaced + )); + assert!(!error.to_string().contains(path.to_string_lossy().as_ref())); + assert!(!error.to_string().contains("one")); +} + +#[tokio::test] +async fn the_step_budget_interrupts_an_expensive_statement() { + let directory = TempDir::new().unwrap(); + let path = database(&directory); + let snapshot = CapturedSnapshot::capture(&path).unwrap(); + let mut bounded = contract( + "WITH RECURSIVE counter(n) AS (\ + SELECT 1 UNION ALL SELECT n + 1 FROM counter WHERE n < 50000000\ + ) SELECT printf('%d', COUNT(*)) AS id FROM counter WHERE :active = :active", + ); + bounded.limits.maximum_statement_steps = 1_000; + let statement = ReadOnlyStatement::open(DatabaseProfile::Snapshot(snapshot), bounded).unwrap(); + let error = statement + .execute(&BTreeMap::from([("active".to_owned(), Value::Integer(1))])) + .await + .unwrap_err(); + assert_eq!(error.kind(), ErrorKind::StepBudgetExceeded); +} + +#[tokio::test] +async fn the_time_budget_interrupts_an_expensive_statement() { + let directory = TempDir::new().unwrap(); + let path = database(&directory); + let snapshot = CapturedSnapshot::capture(&path).unwrap(); + let mut bounded = contract( + "WITH RECURSIVE counter(n) AS (\ + SELECT 1 UNION ALL SELECT n + 1 FROM counter WHERE n < 50000000\ + ) SELECT printf('%d', COUNT(*)) AS id FROM counter WHERE :active = :active", + ); + bounded.limits.maximum_statement_steps = 100_000_000; + bounded.limits.timeout = Duration::from_millis(1); + let statement = ReadOnlyStatement::open(DatabaseProfile::Snapshot(snapshot), bounded).unwrap(); + let error = statement + .execute(&BTreeMap::from([("active".to_owned(), Value::Integer(1))])) + .await + .unwrap_err(); + assert!(matches!( + error.kind(), + ErrorKind::TimeBudgetExceeded | ErrorKind::Timeout + )); +} + +#[cfg(feature = "fixture")] +#[tokio::test] +async fn queue_time_is_bounded_and_admission_recovers() { + let directory = TempDir::new().unwrap(); + let path = database(&directory); + let snapshot = CapturedSnapshot::capture(&path).unwrap(); + let mut bounded = contract("SELECT id FROM records WHERE active = :active ORDER BY id"); + bounded.limits.timeout = Duration::from_millis(10); + let statement = ReadOnlyStatement::open(DatabaseProfile::Snapshot(snapshot), bounded).unwrap(); + let held = statement.hold_all_permits_for_test().await.unwrap(); + let error = statement + .execute(&BTreeMap::from([("active".to_owned(), Value::Integer(1))])) + .await + .unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Timeout); + + drop(held); + let result = statement + .execute(&BTreeMap::from([("active".to_owned(), Value::Integer(1))])) + .await + .unwrap(); + assert_eq!(result.rows.len(), 1); +} + +#[test] +fn live_reads_allow_content_updates_but_refuse_path_replacement() { + let directory = TempDir::new().unwrap(); + let path = database(&directory); + let live = LiveDatabaseFile::bind(&path).unwrap(); + live.confirm_still_bound().unwrap(); + fs::rename(&path, directory.path().join("old.sqlite")).unwrap(); + let replacement = database(&directory); + assert_eq!(replacement, path); + assert_eq!( + live.confirm_still_bound().unwrap_err().kind(), + ErrorKind::DatabaseReplaced + ); +} + +#[tokio::test] +async fn live_reads_require_and_reverify_the_schema_inside_each_read() { + let directory = TempDir::new().unwrap(); + let path = database(&directory); + make_writable(&path); + + let live = LiveDatabaseFile::bind(&path).unwrap(); + let profile = DatabaseProfile::LiveReadOnly(live); + let limits = InspectionLimits { + maximum_objects: 16, + maximum_sql_bytes: 4096, + maximum_statement_steps: 100_000, + timeout: Duration::from_secs(1), + }; + let catalog = inspect_schema(&profile, &limits).unwrap(); + + let missing = ReadOnlyStatement::open( + profile.clone(), + contract("SELECT id FROM records WHERE active = :active ORDER BY id"), + ) + .err() + .unwrap(); + assert_eq!(missing.kind(), ErrorKind::InvalidPlan); + + let mut bound = contract("SELECT id FROM records WHERE active = :active ORDER BY id"); + bound.schema = Some(SchemaBinding { + expected_fingerprint: catalog.fingerprint.clone(), + maximum_objects: limits.maximum_objects, + maximum_sql_bytes: limits.maximum_sql_bytes, + }); + let statement = ReadOnlyStatement::open(profile, bound).unwrap(); + + let connection = Connection::open(&path).unwrap(); + connection + .execute("INSERT INTO records VALUES ('three', 1)", []) + .unwrap(); + connection.close().unwrap(); + let result = statement + .execute(&BTreeMap::from([("active".to_owned(), Value::Integer(1))])) + .await + .unwrap(); + assert_eq!(result.rows.len(), 2); + assert_eq!(result.provenance.profile, DatabaseProfileKind::LiveReadOnly); + assert_eq!(result.provenance.source_revision, None); + assert_eq!( + result.provenance.schema_fingerprint.as_deref(), + Some(catalog.fingerprint.as_str()) + ); + + let connection = Connection::open(&path).unwrap(); + connection + .execute_batch("ALTER TABLE records ADD COLUMN protected_value TEXT") + .unwrap(); + connection.close().unwrap(); + let error = statement + .execute(&BTreeMap::from([("active".to_owned(), Value::Integer(1))])) + .await + .unwrap_err(); + assert_eq!(error.kind(), ErrorKind::SchemaMismatch); + assert!(!error.to_string().contains("protected_value")); +} + +#[test] +fn schema_inspection_is_ordered_bounded_and_fingerprinted() { + let directory = TempDir::new().unwrap(); + let path = database(&directory); + let snapshot = CapturedSnapshot::capture(&path).unwrap(); + let catalog = inspect_schema( + &DatabaseProfile::Snapshot(snapshot), + &InspectionLimits { + maximum_objects: 16, + maximum_sql_bytes: 4096, + maximum_statement_steps: 100_000, + timeout: Duration::from_secs(1), + }, + ) + .unwrap(); + assert!(catalog.fingerprint.starts_with("sha256:")); + assert_eq!(catalog.objects[0].name, "records"); + assert_eq!( + catalog.objects[0] + .columns + .iter() + .map(|column| ( + column.name.as_str(), + column.declared_type.as_str(), + column.nullable, + column.primary_key, + )) + .collect::>(), + vec![ + ("id", "TEXT", true, false), + ("active", "INTEGER", true, false), + ] + ); + let rendered = format!("{catalog:?}"); + assert!(!rendered.contains("one")); + assert!(!rendered.contains("two")); +} + +#[test] +fn errors_never_render_sql_paths_or_values() { + let directory = TempDir::new().unwrap(); + let path = database(&directory); + let snapshot = CapturedSnapshot::capture(&path).unwrap(); + let error = ReadOnlyStatement::open( + DatabaseProfile::Snapshot(snapshot), + contract("SELECT secret_column FROM records WHERE id = 'protected-value'"), + ) + .err() + .unwrap(); + let rendered = error.to_string(); + assert!(!rendered.contains("secret_column")); + assert!(!rendered.contains("protected-value")); + assert!(!rendered.contains(path.to_string_lossy().as_ref())); +} diff --git a/crates/registry-relay-v2/Cargo.toml b/crates/registry-relay-v2/Cargo.toml new file mode 100644 index 000000000..72e3efb12 --- /dev/null +++ b/crates/registry-relay-v2/Cargo.toml @@ -0,0 +1,68 @@ +[package] +name = "registry-relay-v2" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "Compiled read-only Registry Relay runtime." +repository.workspace = true +publish = false +readme = "README.md" + +[[bin]] +name = "relay" +path = "src/main.rs" + +[lints] +workspace = true + +[features] +default = [] +tooling = ["dep:tempfile", "registry-platform-sqlite/fixture"] + +[dependencies] +axum.workspace = true +base64.workspace = true +bytes.workspace = true +chrono = { workspace = true, features = ["serde"] } +clap.workspace = true +hex.workspace = true +hmac.workspace = true +http.workspace = true +jsonwebtoken.workspace = true +registry-platform-audit.workspace = true +registry-platform-buildinfo.workspace = true +registry-platform-canonical-json.workspace = true +registry-platform-config.workspace = true +registry-platform-httpsec.workspace = true +registry-platform-oidc.workspace = true +registry-platform-sqlite.workspace = true +reqwest.workspace = true +rustix.workspace = true +serde.workspace = true +serde_json.workspace = true +serde_norway.workspace = true +sha2.workspace = true +thiserror.workspace = true +tempfile = { workspace = true, optional = true } +tokio.workspace = true +tower.workspace = true +tower-http.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true +ulid.workspace = true +url.workspace = true +zeroize.workspace = true + +[dev-dependencies] +async-trait.workspace = true +futures.workspace = true +registry-platform-httputil.workspace = true +registry-platform-sqlite = { workspace = true, features = ["fixture"] } +registry-platform-testing.workspace = true +tempfile.workspace = true +utoipa.workspace = true + +[[test]] +name = "process_http" +required-features = ["tooling"] diff --git a/crates/registry-relay-v2/README.md b/crates/registry-relay-v2/README.md new file mode 100644 index 000000000..591c5b6c6 --- /dev/null +++ b/crates/registry-relay-v2/README.md @@ -0,0 +1,16 @@ +# Registry Relay V2 + +`registry-relay-v2` compiles one governed Registry contract into an immutable, +read-only runtime model. It owns Relay V2 contract semantics, deterministic +semantic artifacts, offline fixture evaluation, change classification, and +sealed package construction. + +The compiler is deliberately independent of SQLite access. Callers inspect a +database through `registry-platform-sqlite`, pass the resulting +`ObservedSourceSchema` to the compiler, and execute the compiler's closed query +plans through that same platform boundary. The crate does not depend on Relay +V1 or Registry Manifest and does not define a generic storage abstraction. + +`relay` is the runtime entry point. `relayctl` links this library directly for +authoring workflows, so command-line code must not reproduce validation, +generation, fixture, diff, or packaging rules. diff --git a/crates/registry-relay-v2/src/api.rs b/crates/registry-relay-v2/src/api.rs new file mode 100644 index 000000000..6ee91fe2d --- /dev/null +++ b/crates/registry-relay-v2/src/api.rs @@ -0,0 +1,2517 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Fixed Relay V2 HTTP handlers over the immutable compiled kernel. + +use std::collections::{BTreeMap, BTreeSet}; +use std::io; +use std::sync::Arc; + +use axum::body::{to_bytes, Body}; +use axum::extract::{Path, State}; +use axum::http::header::{ACCEPT, CACHE_CONTROL, CONTENT_TYPE, ETAG, IF_NONE_MATCH, VARY}; +use axum::http::{HeaderMap, HeaderValue, Request, Response, StatusCode, Uri}; +use chrono::{DateTime, NaiveDate}; +use registry_platform_canonical_json::canonicalize_json; +use registry_platform_sqlite::{ResultRow, Value as SqlValue}; +use serde::Deserialize; +use serde_json::{json, Map, Value}; +use sha2::{Digest, Sha256}; + +use crate::artifacts::GeneratedArtifact; +use crate::audit::{AuditContext, AuditOutcome, PrincipalKind, RelayAudit, RowBoundaryKind}; +use crate::auth::{bearer_token, Authorization, AuthorizationError, Principal}; +use crate::contract::{DataType, Handling, OrderedMap, Visibility}; +use crate::cursor::{ + decode as decode_cursor, encode as encode_cursor, now_unix_seconds, require_same_request, + CursorBindings, CursorPayload, CursorValue, +}; +use crate::model::{ + CompiledAccess, CompiledOperation, CompiledResource, OperationKind, RowAuthoritySource, +}; +use crate::problem::{ProblemCode, TraceContext}; +use crate::server::{uri_within_bound, RelayService}; +use crate::sqlite_runtime::{OperationQuery, SourceRevision, SqliteRuntimeError}; + +const PRODUCT_NAME: &str = "Registry Relay"; +const PRODUCT_VERSION: &str = "2"; +const API_BINDING_NAME: &str = "registry-relay-http"; +const API_BINDING_VERSION: &str = "v2"; +const METADATA_DEFAULT_PAGE_SIZE: usize = 50; +const METADATA_MAXIMUM_PAGE_SIZE: usize = 100; +const MAXIMUM_SERIALIZED_RESPONSE_BYTES: usize = 8 * 1024 * 1024; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Representation { + Json, + JsonLd, +} + +impl Representation { + const fn media_type(self) -> &'static str { + match self { + Self::Json => "application/json", + Self::JsonLd => "application/ld+json", + } + } +} + +#[derive(Clone)] +struct Access { + principal: Option, + authorization: Authorization, +} + +pub async fn health() -> Response { + minimal_status("ok") +} + +pub async fn ready(State(service): State>) -> Response { + if service.is_ready().await { + minimal_status("ready") + } else { + ProblemCode::ServiceNotReady.response(&TraceContext::server_created()) + } +} + +pub async fn openapi( + State(service): State>, + headers: HeaderMap, + uri: Uri, +) -> Response { + let trace = TraceContext::from_headers(&headers); + if let Some(response) = preflight_public(&service, &headers, &uri, &trace).await { + return response; + } + let Some(artifact) = service.artifacts.get("openapi.public.json") else { + return ProblemCode::Internal.response(&trace); + }; + static_bytes_response( + &artifact.content, + "application/json", + true, + &headers, + &trace, + ) +} + +pub async fn service_metadata( + State(service): State>, + headers: HeaderMap, + uri: Uri, +) -> Response { + let trace = TraceContext::from_headers(&headers); + if !uri_within_bound(&uri) { + return ProblemCode::UriTooLong.response(&trace); + } + let principal = match optional_principal(&service, &headers).await { + Ok(value) => value, + Err(code) => return code.response(&trace), + }; + let mut capabilities = Vec::new(); + if service.registry.metadata_visibility.resources != Visibility::OperatorOnly { + for resource in &service.registry.resources { + let operations = match service.registry.metadata_visibility.resources { + Visibility::Public => resource + .operations + .iter() + .filter(|operation| matches!(operation.access, CompiledAccess::Public)) + .collect::>(), + Visibility::OperationBound => match principal.as_ref() { + Some(principal) => { + match visible_operations(&service, resource, Some(principal)).await { + Ok(value) => value, + Err(code) => return code.response(&trace), + } + } + None => Vec::new(), + }, + Visibility::OperatorOnly => Vec::new(), + }; + capabilities.extend( + operations + .into_iter() + .map(|operation| capability(&service, resource, operation)), + ); + } + } + let alignment_targets = service + .metadata + .alignment_targets + .iter() + .map(|target| { + json!({ + "name": target.name, + "version": target.version, + "status": target.status, + "cfrTarget": target.cfr_target, + }) + }) + .collect::>(); + let value = json!({ + "registryIdentifier": service.registry.registry_identifier, + "name": service.registry.registry_name, + "authority": { + "identifier": service.metadata.authority.identifier, + "name": service.metadata.authority.name, + }, + "operator": service.metadata.operator.as_ref().map(|item| json!({ + "identifier": item.identifier, + "name": item.name, + })), + "authoritativeScope": service.metadata.authoritative_scope, + "product": {"name": PRODUCT_NAME, "version": PRODUCT_VERSION}, + "apiBinding": {"name": API_BINDING_NAME, "version": API_BINDING_VERSION}, + "alignmentTargets": alignment_targets, + "capabilities": capabilities, + "links": { + "self": absolute(&service.registry.base_uri, "/v2"), + "resources": absolute(&service.registry.base_uri, "/v2/resources"), + "openapi": absolute(&service.registry.base_uri, "/openapi.json"), + } + }); + json_metadata_response( + value, + service.registry.metadata_visibility.resources == Visibility::Public, + &headers, + &trace, + ) +} + +pub async fn resource_list( + State(service): State>, + headers: HeaderMap, + uri: Uri, +) -> Response { + let trace = TraceContext::from_headers(&headers); + if !uri_within_bound(&uri) { + return ProblemCode::UriTooLong.response(&trace); + } + let principal = match optional_principal(&service, &headers).await { + Ok(value) => value, + Err(code) => return code.response(&trace), + }; + let mut visible = match visible_resources(&service, principal.as_ref()).await { + Ok(value) => value, + Err(code) => return code.response(&trace), + }; + visible.sort_by(|(left, _), (right, _)| left.id.cmp(&right.id)); + let query = match parse_query(uri.query()) { + Ok(value) => value, + Err(code) => return code.response(&trace), + }; + let (page_size, start) = if query.iter().any(|(name, _)| name == "cursor") { + if query.len() != 1 || query[0].0 != "cursor" || query[0].1.is_empty() { + return ProblemCode::CursorInvalid.response(&trace); + } + let Some(key) = service.cursor_key.as_ref() else { + return ProblemCode::CursorInvalid.response(&trace); + }; + let payload = match decode_cursor(key, &query[0].1, now_unix_seconds()) { + Ok(value) => value, + Err(_) => return ProblemCode::CursorInvalid.response(&trace), + }; + let request = match metadata_cursor_template(&service, &visible) { + Ok(value) => value, + Err(code) => return code.response(&trace), + }; + if require_same_request(&payload, &request).is_err() + || payload.page_size == 0 + || usize::try_from(payload.page_size) + .ok() + .is_none_or(|value| value > METADATA_MAXIMUM_PAGE_SIZE) + { + return ProblemCode::CursorInvalid.response(&trace); + } + let Some(position) = visible + .iter() + .position(|(resource, _)| resource.id == payload.last_record_identifier) + else { + return ProblemCode::CursorInvalid.response(&trace); + }; + ( + usize::try_from(payload.page_size).unwrap_or(METADATA_MAXIMUM_PAGE_SIZE), + position.saturating_add(1), + ) + } else { + if query.iter().any(|(name, _)| name != "pageSize") { + return ProblemCode::ConsultationInvalidRequest.response(&trace); + } + let page_size = match one_parameter(&query, "pageSize") { + Ok(Some(value)) => match value.parse::() { + Ok(value) if (1..=METADATA_MAXIMUM_PAGE_SIZE).contains(&value) => value, + _ => return ProblemCode::ConsultationInvalidRequest.response(&trace), + }, + Ok(None) => METADATA_DEFAULT_PAGE_SIZE, + Err(code) => return code.response(&trace), + }; + (page_size, 0) + }; + let mut page = visible + .iter() + .skip(start) + .take(page_size.saturating_add(1)) + .cloned() + .collect::>(); + let has_next = page.len() > page_size; + if has_next { + page.pop(); + } + let next_cursor = if has_next { + let Some((last, _)) = page.last() else { + return ProblemCode::Internal.response(&trace); + }; + match metadata_next_cursor(&service, &visible, page_size, &last.id) { + Ok(value) => Some(value), + Err(code) => return code.response(&trace), + } + } else { + None + }; + let items = page + .into_iter() + .map(|(resource, operations)| resource_document(&service, resource, &operations)) + .collect::>(); + json_metadata_response( + json!({ + "items": items, + "pageInfo": {"nextCursor": next_cursor}, + "meta": {"registryIdentifier": service.registry.registry_identifier}, + }), + service.registry.metadata_visibility.resources == Visibility::Public, + &headers, + &trace, + ) +} + +pub async fn resource_metadata( + State(service): State>, + Path(resource_id): Path, + headers: HeaderMap, + uri: Uri, +) -> Response { + let trace = TraceContext::from_headers(&headers); + if !uri_within_bound(&uri) { + return ProblemCode::UriTooLong.response(&trace); + } + let principal = match optional_principal(&service, &headers).await { + Ok(value) => value, + Err(code) => return code.response(&trace), + }; + let Some(resource) = service + .registry + .resources + .iter() + .find(|item| item.id == resource_id) + else { + if principal.is_none() && protected_metadata_exists(&service) { + return ProblemCode::MissingCredential.response(&trace); + } + return ProblemCode::ResourceNotFound.response(&trace); + }; + let operations = match visible_operations(&service, resource, principal.as_ref()).await { + Ok(value) if !value.is_empty() => value, + Ok(_) => return ProblemCode::ResourceNotFound.response(&trace), + Err(code) => return code.response(&trace), + }; + json_metadata_response( + json!({ + "data": resource_document(&service, resource, &operations), + "meta": {"registryIdentifier": service.registry.registry_identifier}, + }), + service.registry.metadata_visibility.resources == Visibility::Public, + &headers, + &trace, + ) +} + +pub async fn artifact( + State(service): State>, + Path(artifact_identifier): Path, + headers: HeaderMap, + uri: Uri, +) -> Response { + let trace = TraceContext::from_headers(&headers); + if !uri_within_bound(&uri) { + return ProblemCode::UriTooLong.response(&trace); + } + let principal = match optional_principal(&service, &headers).await { + Ok(value) => value, + Err(code) => return code.response(&trace), + }; + let Some(artifact) = service + .artifacts + .artifacts + .iter() + .find(|item| item.id == artifact_identifier) + else { + if principal.is_none() && service.artifacts.artifacts.iter().any(protected_artifact) { + return ProblemCode::MissingCredential.response(&trace); + } + return ProblemCode::ResourceNotFound.response(&trace); + }; + match artifact.visibility { + Visibility::OperatorOnly => return ProblemCode::ResourceNotFound.response(&trace), + Visibility::Public => {} + Visibility::OperationBound => { + let Some(principal) = principal.as_ref() else { + return ProblemCode::MissingCredential.response(&trace); + }; + let Some(identifier) = artifact.operation_identifier.as_deref() else { + return ProblemCode::ResourceNotFound.response(&trace); + }; + let Some(operation) = find_operation_by_id(&service, identifier) else { + return ProblemCode::ResourceNotFound.response(&trace); + }; + let Some(authenticator) = &service.authenticator else { + return ProblemCode::ResourceNotFound.response(&trace); + }; + if authenticator + .authorize(&operation.access, Some(principal)) + .is_err() + { + return ProblemCode::ResourceNotFound.response(&trace); + } + } + } + static_bytes_response( + &artifact.content, + &artifact.media_type, + artifact.visibility == Visibility::Public, + &headers, + &trace, + ) +} + +pub async fn record_list( + State(service): State>, + Path(resource_id): Path, + headers: HeaderMap, + uri: Uri, +) -> Response { + let trace = TraceContext::from_headers(&headers); + let Some((resource, operation)) = find_operation(&service, &resource_id, |kind| { + matches!(kind, OperationKind::List) + }) else { + return unknown_data_route(&service, &headers, &trace, OperationClass::List).await; + }; + if !uri_within_bound(&uri) { + return refuse_known( + &service, + resource, + operation, + None, + AuditOutcome::InvalidRequest, + ProblemCode::UriTooLong, + &trace, + ) + .await; + } + let access = match access_operation(&service, resource, operation, &headers, &trace).await { + Ok(value) => value, + Err(response) => return response, + }; + if rejects_caller_purpose(&headers) { + return refuse_known( + &service, + resource, + operation, + Some(&access), + AuditOutcome::InvalidRequest, + ProblemCode::ConsultationInvalidRequest, + &trace, + ) + .await; + } + let representation = match negotiate(&headers) { + Ok(value) => value, + Err(code) => { + return refuse_known( + &service, + resource, + operation, + Some(&access), + AuditOutcome::InvalidRequest, + code, + &trace, + ) + .await + } + }; + let query = match prepare_list(&service, resource, operation, &access, uri.query()) { + Ok(value) => value, + Err(code) => { + return refuse_known( + &service, + resource, + operation, + Some(&access), + AuditOutcome::InvalidRequest, + code, + &trace, + ) + .await + } + }; + if let Some(response) = quota_refusal( + &service, + resource, + operation, + &access, + &query.selected_fields, + &trace, + ) + .await + { + return response; + } + let audit = audit_context( + &service, + resource, + operation, + &access, + query.selected_fields.clone(), + &trace, + ); + if service.audit.attempt(&audit).await.is_err() { + return ProblemCode::AuditUnavailable.response(&trace); + } + let result = service + .sqlite + .execute( + &operation.identifier, + OperationQuery { + filters: query.filters.clone(), + row_authority: access.authorization.row_authority.clone(), + after_order: query.after_order.clone(), + fetch_limit: Some(query.page_size.saturating_add(1)), + ..OperationQuery::default() + }, + ) + .await; + let result = match result { + Ok(value) => value, + Err(error) => return source_failure(&service.audit, &audit, error, &trace).await, + }; + let mut rows = result.rows; + let has_next = rows.len() > usize::try_from(query.page_size).unwrap_or(usize::MAX); + if has_next { + rows.pop(); + } + let mut items = Vec::with_capacity(rows.len()); + for row in &rows { + let record = match record_value(&service, resource, operation, row, &query.selected_fields) + { + Some(value) => value, + None => { + if service + .audit + .terminal(&audit, AuditOutcome::InternalFailed, None) + .await + .is_err() + { + return ProblemCode::AuditUnavailable.response(&trace); + } + return ProblemCode::Internal.response(&trace); + } + }; + items.push(record); + } + let next_cursor = if has_next { + let Some(last) = rows.last() else { + return terminal_problem( + &service.audit, + &audit, + AuditOutcome::InternalFailed, + ProblemCode::Internal, + &trace, + ) + .await; + }; + match next_cursor( + &service, + operation, + &access, + &query, + last, + &result.source_revision, + ) { + Ok(value) => Some(value), + Err(_) => { + return terminal_problem( + &service.audit, + &audit, + AuditOutcome::InternalFailed, + ProblemCode::Internal, + &trace, + ) + .await + } + } + } else { + None + }; + let mut document = json!({ + "items": items, + "pageInfo": {"nextCursor": next_cursor}, + "meta": record_meta( + &service, + resource, + operation, + &query.selected_fields, + &result.source_revision, + ), + }); + apply_json_ld(&service, resource, operation, representation, &mut document); + release_document( + &service, + &audit, + document, + representation, + cacheable(operation, &result.source_revision), + &headers, + &trace, + ) + .await +} + +pub async fn record_read( + State(service): State>, + Path((resource_id, record_identifier)): Path<(String, String)>, + headers: HeaderMap, + uri: Uri, +) -> Response { + let trace = TraceContext::from_headers(&headers); + let Some((resource, operation)) = find_operation(&service, &resource_id, |kind| { + matches!(kind, OperationKind::Read) + }) else { + return unknown_data_route(&service, &headers, &trace, OperationClass::Read).await; + }; + let access = match access_operation(&service, resource, operation, &headers, &trace).await { + Ok(value) => value, + Err(response) => return response, + }; + if !uri_within_bound(&uri) { + return refuse_known( + &service, + resource, + operation, + Some(&access), + AuditOutcome::InvalidRequest, + ProblemCode::UriTooLong, + &trace, + ) + .await; + } + if !valid_record_identifier(&record_identifier) { + return refuse_known( + &service, + resource, + operation, + Some(&access), + AuditOutcome::Unresolved, + ProblemCode::ConsultationUnresolved, + &trace, + ) + .await; + } + single_operation( + &service, + resource, + operation, + access, + SingleRequest { + headers: &headers, + query_text: uri.query(), + query: OperationQuery { + record_identifier: Some(record_identifier), + ..OperationQuery::default() + }, + prevalidated: None, + quota_admitted: false, + trace: &trace, + }, + ) + .await +} + +pub async fn record_lookup( + State(service): State>, + Path((resource_id, lookup_id)): Path<(String, String)>, + request: Request, +) -> Response { + let trace = TraceContext::from_headers(request.headers()); + let Some((resource, operation)) = find_operation( + &service, + &resource_id, + |kind| matches!(kind, OperationKind::Lookup { name } if name == &lookup_id), + ) else { + return unknown_data_route(&service, request.headers(), &trace, OperationClass::Lookup) + .await; + }; + let access = + match access_operation(&service, resource, operation, request.headers(), &trace).await { + Ok(value) => value, + Err(response) => return response, + }; + if !uri_within_bound(request.uri()) { + return refuse_known( + &service, + resource, + operation, + Some(&access), + AuditOutcome::InvalidRequest, + ProblemCode::UriTooLong, + &trace, + ) + .await; + } + if rejects_caller_purpose(request.headers()) { + return refuse_known( + &service, + resource, + operation, + Some(&access), + AuditOutcome::InvalidRequest, + ProblemCode::ConsultationInvalidRequest, + &trace, + ) + .await; + } + let representation = match negotiate(request.headers()) { + Ok(value) => value, + Err(code) => { + return refuse_known( + &service, + resource, + operation, + Some(&access), + AuditOutcome::InvalidRequest, + code, + &trace, + ) + .await + } + }; + let fields = match selected_fields(resource, operation, request.uri().query()) { + Ok(value) => value, + Err(code) => { + return refuse_known( + &service, + resource, + operation, + Some(&access), + AuditOutcome::InvalidRequest, + code, + &trace, + ) + .await + } + }; + if !is_json_content_type(request.headers()) { + return refuse_known( + &service, + resource, + operation, + Some(&access), + AuditOutcome::InvalidRequest, + ProblemCode::UnsupportedMediaType, + &trace, + ) + .await; + } + if let Some(response) = + quota_refusal(&service, resource, operation, &access, &fields, &trace).await + { + return response; + } + let (parts, body) = request.into_parts(); + let Some(maximum) = operation.query.maximum_request_body_bytes else { + return ProblemCode::Internal.response(&trace); + }; + let maximum = match usize::try_from(maximum) { + Ok(value) => value, + Err(_) => return ProblemCode::Internal.response(&trace), + }; + let bytes = match tokio::time::timeout(service.request_timeout, to_bytes(body, maximum)).await { + Ok(Ok(value)) => value, + Ok(Err(_)) => { + return refuse_known( + &service, + resource, + operation, + Some(&access), + AuditOutcome::InvalidRequest, + ProblemCode::BodyTooLarge, + &trace, + ) + .await + } + Err(_) => { + return refuse_known( + &service, + resource, + operation, + Some(&access), + AuditOutcome::TimedOut, + ProblemCode::Timeout, + &trace, + ) + .await + } + }; + let selectors = match parse_selectors(&service, operation, &bytes) { + Ok(value) => value, + Err(code) => { + return refuse_known( + &service, + resource, + operation, + Some(&access), + AuditOutcome::InvalidRequest, + code, + &trace, + ) + .await + } + }; + single_operation( + &service, + resource, + operation, + access, + SingleRequest { + headers: &parts.headers, + query_text: parts.uri.query(), + query: OperationQuery { + selectors, + ..OperationQuery::default() + }, + prevalidated: Some((representation, fields)), + quota_admitted: true, + trace: &trace, + }, + ) + .await +} + +pub async fn not_found( + State(service): State>, + headers: HeaderMap, +) -> Response { + let trace = TraceContext::from_headers(&headers); + if let Err(code) = optional_principal(&service, &headers).await { + return code.response(&trace); + } + ProblemCode::ResourceNotFound.response(&trace) +} + +struct SingleRequest<'a> { + headers: &'a HeaderMap, + query_text: Option<&'a str>, + query: OperationQuery, + prevalidated: Option<(Representation, Vec)>, + quota_admitted: bool, + trace: &'a TraceContext, +} + +async fn single_operation( + service: &RelayService, + resource: &CompiledResource, + operation: &CompiledOperation, + access: Access, + mut request: SingleRequest<'_>, +) -> Response { + let headers = request.headers; + let trace = request.trace; + let (representation, fields) = match request.prevalidated.take() { + Some(value) => value, + None => { + if rejects_caller_purpose(headers) { + return refuse_known( + service, + resource, + operation, + Some(&access), + AuditOutcome::InvalidRequest, + ProblemCode::ConsultationInvalidRequest, + trace, + ) + .await; + } + let representation = match negotiate(headers) { + Ok(value) => value, + Err(code) => { + return refuse_known( + service, + resource, + operation, + Some(&access), + AuditOutcome::InvalidRequest, + code, + trace, + ) + .await + } + }; + let fields = match selected_fields(resource, operation, request.query_text) { + Ok(value) => value, + Err(code) => { + return refuse_known( + service, + resource, + operation, + Some(&access), + AuditOutcome::InvalidRequest, + code, + trace, + ) + .await + } + }; + (representation, fields) + } + }; + if !request.quota_admitted { + if let Some(response) = + quota_refusal(service, resource, operation, &access, &fields, trace).await + { + return response; + } + } + let audit = audit_context(service, resource, operation, &access, fields.clone(), trace); + if service.audit.attempt(&audit).await.is_err() { + return ProblemCode::AuditUnavailable.response(trace); + } + request.query.row_authority = access.authorization.row_authority.clone(); + let result = service + .sqlite + .execute(&operation.identifier, request.query) + .await; + let result = match result { + Ok(value) => value, + Err(error) => return source_failure(&service.audit, &audit, error, trace).await, + }; + if result.rows.len() != 1 { + if service + .audit + .terminal(&audit, AuditOutcome::Unresolved, None) + .await + .is_err() + { + return ProblemCode::AuditUnavailable.response(trace); + } + return ProblemCode::ConsultationUnresolved.response(trace); + } + let Some(record) = record_value(service, resource, operation, &result.rows[0], &fields) else { + if service + .audit + .terminal(&audit, AuditOutcome::Unresolved, None) + .await + .is_err() + { + return ProblemCode::AuditUnavailable.response(trace); + } + return ProblemCode::ConsultationUnresolved.response(trace); + }; + let mut document = json!({ + "data": record, + "meta": record_meta(service, resource, operation, &fields, &result.source_revision), + }); + apply_json_ld(service, resource, operation, representation, &mut document); + release_document( + service, + &audit, + document, + representation, + cacheable(operation, &result.source_revision), + headers, + trace, + ) + .await +} + +async fn access_operation( + service: &RelayService, + resource: &CompiledResource, + operation: &CompiledOperation, + headers: &HeaderMap, + trace: &TraceContext, +) -> Result> { + let principal = match optional_principal(service, headers).await { + Ok(value) => value, + Err(code) => { + return Err(refuse_known( + service, + resource, + operation, + None, + AuditOutcome::InvalidCredential, + code, + trace, + ) + .await) + } + }; + let authorization = match &service.authenticator { + Some(authenticator) => authenticator.authorize(&operation.access, principal.as_ref()), + None => match operation.access { + CompiledAccess::Public => Ok(Authorization { + row_authority: None, + purpose: None, + }), + CompiledAccess::Protected { .. } => Err(AuthorizationError::AuthenticationRequired), + }, + }; + match authorization { + Ok(authorization) => Ok(Access { + principal, + authorization, + }), + Err(error) => { + let (code, outcome) = match error { + AuthorizationError::AuthenticationRequired => ( + ProblemCode::MissingCredential, + AuditOutcome::MissingCredential, + ), + AuthorizationError::ScopeDenied + | AuthorizationError::PurposeDenied + | AuthorizationError::BindingDenied => { + (ProblemCode::ConsultationDenied, AuditOutcome::Denied) + } + }; + let denied_access = Access { + principal, + authorization: Authorization { + row_authority: None, + purpose: None, + }, + }; + Err(refuse_known( + service, + resource, + operation, + Some(&denied_access), + outcome, + code, + trace, + ) + .await) + } + } +} + +async fn optional_principal( + service: &RelayService, + headers: &HeaderMap, +) -> Result, ProblemCode> { + let token = bearer_token(headers).map_err(|_| ProblemCode::InvalidCredential)?; + let Some(token) = token else { + return Ok(None); + }; + let authenticator = service + .authenticator + .as_ref() + .ok_or(ProblemCode::InvalidCredential)?; + authenticator + .authenticate(token) + .await + .map(Some) + .map_err(|_| ProblemCode::InvalidCredential) +} + +async fn preflight_public( + service: &RelayService, + headers: &HeaderMap, + uri: &Uri, + trace: &TraceContext, +) -> Option> { + if !uri_within_bound(uri) { + return Some(ProblemCode::UriTooLong.response(trace)); + } + if optional_principal(service, headers).await.is_err() { + return Some(ProblemCode::InvalidCredential.response(trace)); + } + None +} + +#[derive(Clone, Copy)] +enum OperationClass { + List, + Read, + Lookup, +} + +async fn unknown_data_route( + service: &RelayService, + headers: &HeaderMap, + trace: &TraceContext, + class: OperationClass, +) -> Response { + let principal = match optional_principal(service, headers).await { + Ok(value) => value, + Err(code) => { + let audit = unknown_audit_context(service, trace, PrincipalKind::Unknown); + if service + .audit + .refusal(&audit, AuditOutcome::InvalidCredential) + .await + .is_err() + { + return ProblemCode::AuditUnavailable.response(trace); + } + return code.response(trace); + } + }; + let protected = service.registry.resources.iter().any(|resource| { + resource.operations.iter().any(|operation| { + class_matches(&operation.kind, class) + && matches!(operation.access, CompiledAccess::Protected { .. }) + }) + }); + if protected && principal.is_none() { + let audit = unknown_audit_context(service, trace, PrincipalKind::Unknown); + if service + .audit + .refusal(&audit, AuditOutcome::MissingCredential) + .await + .is_err() + { + return ProblemCode::AuditUnavailable.response(trace); + } + return ProblemCode::MissingCredential.response(trace); + } + let audit = unknown_audit_context( + service, + trace, + if principal.is_some() { + PrincipalKind::Authenticated + } else { + PrincipalKind::Anonymous + }, + ); + if service + .audit + .refusal(&audit, AuditOutcome::NotFound) + .await + .is_err() + { + return ProblemCode::AuditUnavailable.response(trace); + } + ProblemCode::ResourceNotFound.response(trace) +} + +fn class_matches(kind: &OperationKind, class: OperationClass) -> bool { + matches!( + (kind, class), + (OperationKind::List, OperationClass::List) + | (OperationKind::Read, OperationClass::Read) + | (OperationKind::Lookup { .. }, OperationClass::Lookup) + ) +} + +async fn refuse_known( + service: &RelayService, + resource: &CompiledResource, + operation: &CompiledOperation, + access: Option<&Access>, + outcome: AuditOutcome, + code: ProblemCode, + trace: &TraceContext, +) -> Response { + let access = access.cloned().unwrap_or(Access { + principal: None, + authorization: Authorization { + row_authority: None, + purpose: None, + }, + }); + let context = audit_context(service, resource, operation, &access, Vec::new(), trace); + if service.audit.refusal(&context, outcome).await.is_err() { + return ProblemCode::AuditUnavailable.response(trace); + } + code.response(trace) +} + +async fn quota_refusal( + service: &RelayService, + resource: &CompiledResource, + operation: &CompiledOperation, + access: &Access, + fields: &[String], + trace: &TraceContext, +) -> Option> { + let denied = service + .quota + .as_ref() + .is_some_and(|limiter| !limiter.admit(&operation.identifier)); + if !denied { + return None; + } + let context = audit_context(service, resource, operation, access, fields.to_vec(), trace); + if service + .audit + .refusal(&context, AuditOutcome::RateLimited) + .await + .is_err() + { + return Some(ProblemCode::AuditUnavailable.response(trace)); + } + Some(ProblemCode::RateLimited.response(trace)) +} + +fn audit_context( + service: &RelayService, + resource: &CompiledResource, + operation: &CompiledOperation, + access: &Access, + selected_properties: Vec, + trace: &TraceContext, +) -> AuditContext { + AuditContext { + operation_id: RelayAudit::operation_id(), + trace_id: trace.trace_id.clone(), + registry_identifier: service.registry.registry_identifier.clone(), + resource_identifier: Some(resource.id.clone()), + operation_identifier: Some(operation.identifier.clone()), + access_rule_revision: access_revision(operation), + purpose: access.authorization.purpose.clone(), + row_boundary_kind: row_boundary(operation), + disclosure_profile: Some(operation.disclosure_profile.clone()), + processing_description_identifiers: processing_description_identifiers(resource, operation), + selected_properties, + maximum_handling: Some(handling_label(operation.maximum_handling).into()), + contract_revision: service.registry.contract_revision.clone(), + source_revision: service + .sqlite + .source_revision(&operation.identifier) + .cloned(), + principal_kind: if access.principal.is_some() { + PrincipalKind::Authenticated + } else { + PrincipalKind::Anonymous + }, + } +} + +fn unknown_audit_context( + service: &RelayService, + trace: &TraceContext, + principal_kind: PrincipalKind, +) -> AuditContext { + AuditContext { + operation_id: RelayAudit::operation_id(), + trace_id: trace.trace_id.clone(), + registry_identifier: service.registry.registry_identifier.clone(), + resource_identifier: None, + operation_identifier: None, + access_rule_revision: None, + purpose: None, + row_boundary_kind: RowBoundaryKind::Unknown, + disclosure_profile: None, + processing_description_identifiers: Vec::new(), + selected_properties: Vec::new(), + maximum_handling: None, + contract_revision: service.registry.contract_revision.clone(), + source_revision: None, + principal_kind, + } +} + +fn processing_description_identifiers( + resource: &CompiledResource, + operation: &CompiledOperation, +) -> Vec { + let reference = match &operation.kind { + OperationKind::List => "list".to_owned(), + OperationKind::Read => "read".to_owned(), + OperationKind::Lookup { name } => format!("lookup:{name}"), + }; + resource + .processing_descriptions + .iter() + .filter(|description| description.operation_refs.contains(&reference)) + .map(|description| description.id.clone()) + .collect::>() + .into_iter() + .collect() +} + +fn access_revision(operation: &CompiledOperation) -> Option { + let value = serde_json::to_value(&operation.access).ok()?; + let bytes = canonicalize_json(&value).ok()?; + Some(format!("sha256:{}", hex::encode(Sha256::digest(bytes)))) +} + +fn row_boundary(operation: &CompiledOperation) -> RowBoundaryKind { + match &operation.access { + CompiledAccess::Protected { + row_binding: Some(binding), + .. + } => match binding.source { + RowAuthoritySource::Principal => RowBoundaryKind::Principal, + RowAuthoritySource::Claim(_) => RowBoundaryKind::VerifiedClaim, + }, + CompiledAccess::Public + | CompiledAccess::Protected { + row_binding: None, .. + } => RowBoundaryKind::None, + } +} + +fn handling_label(value: Handling) -> &'static str { + match value { + Handling::Public => "public", + Handling::Internal => "internal", + Handling::Confidential => "confidential", + Handling::Restricted => "restricted", + } +} + +struct PreparedList { + page_size: u32, + filters: BTreeMap, + selected_fields: Vec, + after_order: Option>, +} + +fn prepare_list( + service: &RelayService, + resource: &CompiledResource, + operation: &CompiledOperation, + access: &Access, + query: Option<&str>, +) -> Result { + let parameters = parse_query(query)?; + let cursors = parameters + .iter() + .filter(|(name, _)| name == "cursor") + .collect::>(); + let pagination = operation + .query + .pagination + .as_ref() + .ok_or(ProblemCode::Internal)?; + if !cursors.is_empty() { + if cursors.len() != 1 || parameters.len() != 1 { + return Err(ProblemCode::CursorInvalid); + } + let key = service + .cursor_key + .as_ref() + .ok_or(ProblemCode::CursorInvalid)?; + let payload = decode_cursor(key, &cursors[0].1, now_unix_seconds()) + .map_err(|_| ProblemCode::CursorInvalid)?; + if payload.page_size == 0 + || payload.page_size > pagination.maximum_page_size + || payload.last_order_values.len() != operation.query.order_by.len() + { + return Err(ProblemCode::CursorInvalid); + } + let filters = payload + .filters + .iter() + .map(|(name, value)| (name.clone(), cursor_to_sql(value.clone()))) + .collect::>(); + validate_filter_inventory(operation, &filters)?; + validate_selected_inventory(resource, operation, &payload.selected_fields)?; + let current_source_revision = service + .sqlite + .source_revision(&operation.identifier) + .ok_or(ProblemCode::CursorInvalid)? + .cursor_value(); + let request = cursor_template( + service, + operation, + access, + &filters, + &payload.selected_fields, + ¤t_source_revision, + )?; + require_same_request(&payload, &request).map_err(|_| ProblemCode::CursorInvalid)?; + return Ok(PreparedList { + page_size: payload.page_size, + filters, + selected_fields: payload.selected_fields, + after_order: Some( + payload + .last_order_values + .into_iter() + .map(cursor_to_sql) + .collect(), + ), + }); + } + + let mut page_size = pagination.default_page_size; + let mut page_size_seen = false; + let mut fields_text = None; + let declared = operation + .query + .filters + .iter() + .map(|filter| filter.parameter.as_str()) + .collect::>(); + let mut raw_filters = BTreeMap::new(); + for (name, value) in parameters { + match name.as_str() { + "pageSize" => { + if page_size_seen || value.is_empty() { + return Err(ProblemCode::ConsultationInvalidRequest); + } + page_size_seen = true; + page_size = value + .parse::() + .ok() + .filter(|value| *value > 0 && *value <= pagination.maximum_page_size) + .ok_or(ProblemCode::ConsultationInvalidRequest)?; + } + "fields" => { + if fields_text.replace(value).is_some() { + return Err(ProblemCode::FieldsInvalid); + } + } + _ if declared.contains(name.as_str()) => { + if raw_filters.insert(name, value).is_some() { + return Err(ProblemCode::InvalidFilter); + } + } + _ => return Err(ProblemCode::UnknownFilter), + } + } + if raw_filters.is_empty() && !operation.query.allow_unfiltered { + return Err(ProblemCode::InvalidFilter); + } + let mut filters = BTreeMap::new(); + for filter in &operation.query.filters { + if let Some(value) = raw_filters.get(&filter.parameter) { + filters.insert( + filter.parameter.clone(), + parse_text_value(value, filter.data_type).ok_or(ProblemCode::InvalidFilter)?, + ); + if filter.data_type == DataType::ControlledCode { + let property = resource + .properties + .iter() + .find(|property| property.name == filter.property) + .ok_or(ProblemCode::InvalidFilter)?; + if !codelist_accepts(service, property.codelist.as_deref(), value) { + return Err(ProblemCode::InvalidFilter); + } + } + } + } + let selected_fields = fields_from_text(resource, operation, fields_text.as_deref())?; + Ok(PreparedList { + page_size, + filters, + selected_fields, + after_order: None, + }) +} + +fn selected_fields( + resource: &CompiledResource, + operation: &CompiledOperation, + query: Option<&str>, +) -> Result, ProblemCode> { + let parameters = parse_query(query)?; + if parameters.iter().any(|(name, _)| name != "fields") { + return Err(ProblemCode::ConsultationInvalidRequest); + } + let fields = one_parameter(¶meters, "fields")?; + fields_from_text(resource, operation, fields) +} + +fn fields_from_text( + resource: &CompiledResource, + operation: &CompiledOperation, + text: Option<&str>, +) -> Result, ProblemCode> { + let Some(text) = text else { + return Ok(operation.selectable_properties.clone()); + }; + if text.is_empty() || text.bytes().any(|byte| byte.is_ascii_whitespace()) { + return Err(ProblemCode::FieldsInvalid); + } + let requested = text.split(',').collect::>(); + if requested.is_empty() + || requested.iter().any(|field| field.is_empty()) + || requested.iter().collect::>().len() != requested.len() + { + return Err(ProblemCode::FieldsInvalid); + } + let allowed = operation + .selectable_properties + .iter() + .map(String::as_str) + .collect::>(); + if requested.iter().any(|field| { + !allowed.contains(field) + || !resource + .properties + .iter() + .any(|property| property.name == **field) + }) { + return Err(ProblemCode::FieldsInvalid); + } + Ok(operation + .selectable_properties + .iter() + .filter(|field| requested.contains(&field.as_str())) + .cloned() + .collect()) +} + +fn validate_selected_inventory( + resource: &CompiledResource, + operation: &CompiledOperation, + fields: &[String], +) -> Result<(), ProblemCode> { + if fields.is_empty() { + return Err(ProblemCode::CursorInvalid); + } + let text = fields.join(","); + let canonical = fields_from_text(resource, operation, Some(&text))?; + if canonical != fields { + return Err(ProblemCode::CursorInvalid); + } + Ok(()) +} + +fn validate_filter_inventory( + operation: &CompiledOperation, + filters: &BTreeMap, +) -> Result<(), ProblemCode> { + let declared = operation + .query + .filters + .iter() + .map(|filter| filter.parameter.as_str()) + .collect::>(); + if filters.keys().any(|name| !declared.contains(name.as_str())) + || (filters.is_empty() && !operation.query.allow_unfiltered) + { + return Err(ProblemCode::CursorInvalid); + } + Ok(()) +} + +fn parse_query(query: Option<&str>) -> Result, ProblemCode> { + let Some(query) = query else { + return Ok(Vec::new()); + }; + if query.len() > 16 * 1024 || !valid_percent_encoding(query.as_bytes()) { + return Err(ProblemCode::ConsultationInvalidRequest); + } + Ok(url::form_urlencoded::parse(query.as_bytes()) + .map(|(name, value)| (name.into_owned(), value.into_owned())) + .collect()) +} + +fn valid_percent_encoding(value: &[u8]) -> bool { + let mut index = 0; + while index < value.len() { + if value[index] == b'%' { + if index + 2 >= value.len() + || !value[index + 1].is_ascii_hexdigit() + || !value[index + 2].is_ascii_hexdigit() + { + return false; + } + index += 2; + } + index += 1; + } + true +} + +fn one_parameter<'a>( + parameters: &'a [(String, String)], + name: &str, +) -> Result, ProblemCode> { + let values = parameters + .iter() + .filter(|(candidate, _)| candidate == name) + .map(|(_, value)| value.as_str()) + .collect::>(); + match values.as_slice() { + [] => Ok(None), + [value] => Ok(Some(value)), + _ => Err(if name == "fields" { + ProblemCode::FieldsInvalid + } else { + ProblemCode::ConsultationInvalidRequest + }), + } +} + +fn parse_text_value(value: &str, data_type: DataType) -> Option { + if value.is_empty() || value.len() > 4096 || value.chars().any(char::is_control) { + return None; + } + match data_type { + DataType::String | DataType::ControlledCode => Some(SqlValue::String(value.to_owned())), + DataType::Boolean => match value { + "true" => Some(SqlValue::Boolean(true)), + "false" => Some(SqlValue::Boolean(false)), + _ => None, + }, + DataType::Integer => value.parse::().ok().map(SqlValue::Integer), + DataType::Date => NaiveDate::parse_from_str(value, "%Y-%m-%d") + .ok() + .map(|_| SqlValue::String(value.to_owned())), + DataType::DateTime => DateTime::parse_from_rfc3339(value) + .ok() + .map(|_| SqlValue::String(value.to_owned())), + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct LookupBody { + selectors: OrderedMap, +} + +fn parse_selectors( + service: &RelayService, + operation: &CompiledOperation, + bytes: &[u8], +) -> Result, ProblemCode> { + let body: LookupBody = + serde_json::from_slice(bytes).map_err(|_| ProblemCode::ConsultationInvalidRequest)?; + if body.selectors.len() != operation.query.selectors.len() { + return Err(ProblemCode::ConsultationInvalidRequest); + } + let mut output = BTreeMap::new(); + for selector in &operation.query.selectors { + let value = body + .selectors + .get(&selector.name) + .ok_or(ProblemCode::ConsultationInvalidRequest)?; + let value = json_scalar_to_sql(value, selector.data_type) + .ok_or(ProblemCode::ConsultationInvalidRequest)?; + if selector.data_type == DataType::ControlledCode { + let text = match &value { + SqlValue::String(value) => value.as_str(), + _ => return Err(ProblemCode::ConsultationInvalidRequest), + }; + if !codelist_accepts(service, selector.codelist.as_deref(), text) { + return Err(ProblemCode::ConsultationInvalidRequest); + } + } + if let SqlValue::String(text) = &value { + let length = text.len(); + if selector + .minimum_bytes + .is_some_and(|minimum| length < usize::try_from(minimum).unwrap_or(usize::MAX)) + || selector + .maximum_bytes + .is_some_and(|maximum| length > usize::try_from(maximum).unwrap_or(0)) + { + return Err(ProblemCode::ConsultationInvalidRequest); + } + } + output.insert(selector.name.clone(), value); + } + Ok(output) +} + +fn json_scalar_to_sql(value: &Value, data_type: DataType) -> Option { + match data_type { + DataType::String | DataType::ControlledCode => value + .as_str() + .filter(|value| !value.is_empty() && !value.chars().any(char::is_control)) + .map(|value| SqlValue::String(value.to_owned())), + DataType::Boolean => value.as_bool().map(SqlValue::Boolean), + DataType::Integer => value.as_i64().map(SqlValue::Integer), + DataType::Date => value.as_str().and_then(|value| { + NaiveDate::parse_from_str(value, "%Y-%m-%d") + .ok() + .map(|_| SqlValue::String(value.to_owned())) + }), + DataType::DateTime => value.as_str().and_then(|value| { + DateTime::parse_from_rfc3339(value) + .ok() + .map(|_| SqlValue::String(value.to_owned())) + }), + } +} + +fn record_value( + service: &RelayService, + resource: &CompiledResource, + _operation: &CompiledOperation, + row: &ResultRow, + selected: &[String], +) -> Option { + let record_identifier = + required_string(row, &resource.record_context.record_identifier_column)?; + if !valid_record_identifier(record_identifier) { + return None; + } + let revision = required_string(row, &resource.record_context.revision_identifier_column)?; + let lifecycle = required_string(row, &resource.record_context.lifecycle_state_column)?; + let recorded_at = required_string(row, &resource.record_context.recorded_at_column)?; + DateTime::parse_from_rfc3339(recorded_at).ok()?; + if revision.is_empty() + || lifecycle.is_empty() + || !codelist_accepts( + service, + Some(&resource.record_context.lifecycle_state_codelist), + lifecycle, + ) + { + return None; + } + // Validate the complete reviewed source projection before narrowing. + for property in &resource.properties { + let value = row.get(&property.source_column)?; + if matches!(value, SqlValue::Null) { + if property.source_required { + return None; + } + continue; + } + if !valid_property_value( + service, + value, + property.data_type, + property.codelist.as_deref(), + ) { + return None; + } + } + let mut domain = Map::new(); + for property in &resource.properties { + if !selected.contains(&property.name) { + continue; + } + let value = row.get(&property.source_column)?; + if !matches!(value, SqlValue::Null) { + domain.insert(property.name.clone(), sql_to_json(value.clone())?); + } + } + Some(json!({ + "registryIdentifier": service.registry.registry_identifier, + "recordIdentifier": record_identifier, + "revisionIdentifier": revision, + "lifecycleState": lifecycle, + "schemaReference": _operation.schema_reference, + "semanticModelReference": _operation.semantic_model_reference, + "authorityIdentifier": service.registry.authority_identifier, + "recordedAt": recorded_at, + "domainData": domain, + })) +} + +fn valid_property_value( + service: &RelayService, + value: &SqlValue, + data_type: DataType, + codelist: Option<&str>, +) -> bool { + match (value, data_type) { + (SqlValue::String(_), DataType::String) => true, + (SqlValue::String(value), DataType::ControlledCode) => { + codelist_accepts(service, codelist, value) + } + (SqlValue::String(value), DataType::Date) => { + NaiveDate::parse_from_str(value, "%Y-%m-%d").is_ok() + } + (SqlValue::String(value), DataType::DateTime) => { + DateTime::parse_from_rfc3339(value).is_ok() + } + (SqlValue::Boolean(_), DataType::Boolean) | (SqlValue::Integer(_), DataType::Integer) => { + true + } + _ => false, + } +} + +fn codelist_accepts(service: &RelayService, path: Option<&str>, value: &str) -> bool { + let Some(path) = path else { + return false; + }; + service + .registry + .codelists + .iter() + .find(|codelist| codelist.path == path) + .is_some_and(|codelist| codelist.values.iter().any(|candidate| candidate == value)) +} + +fn required_string<'a>(row: &'a ResultRow, column: &str) -> Option<&'a str> { + match row.get(column)? { + SqlValue::String(value) if !value.is_empty() => Some(value), + _ => None, + } +} + +fn sql_to_json(value: SqlValue) -> Option { + match value { + SqlValue::Null => Some(Value::Null), + SqlValue::String(value) => Some(Value::String(value)), + SqlValue::Integer(value) => Some(json!(value)), + SqlValue::Number(value) if value.is_finite() => Some(json!(value)), + SqlValue::Boolean(value) => Some(Value::Bool(value)), + SqlValue::Number(_) => None, + } +} + +fn record_meta( + service: &RelayService, + resource: &CompiledResource, + operation: &CompiledOperation, + selected: &[String], + source_revision: &SourceRevision, +) -> Value { + let pattern = operation_pattern(&operation.kind); + json!({ + "operationIdentifier": operation.identifier, + "family": "consultation", + "pattern": pattern, + "disclosureProfile": operation.disclosure_profile, + "contractRevision": service.registry.contract_revision, + "sourceRevision": source_revision_value(source_revision), + "selectedFields": selected, + "links": { + "self": operation_href(service, resource, operation), + "context": operation.context_reference, + "schema": operation.schema_reference, + "semanticModel": operation.semantic_model_reference, + } + }) +} + +fn source_revision_value(source: &SourceRevision) -> Value { + match source { + SourceRevision::Snapshot(value) => { + json!({"profile": "snapshot", "status": "versioned", "value": value}) + } + SourceRevision::LiveUnversioned => { + json!({"profile": "live", "status": "unversioned", "value": null}) + } + } +} + +fn apply_json_ld( + service: &RelayService, + resource: &CompiledResource, + operation: &CompiledOperation, + representation: Representation, + document: &mut Value, +) { + if representation != Representation::JsonLd { + return; + } + let context = operation.context_reference.clone(); + if let Some(object) = document.as_object_mut() { + object.insert("@context".into(), Value::String(context)); + if let Some(data) = object.get_mut("data") { + add_record_id(service, resource, data); + } + if let Some(items) = object.get_mut("items").and_then(Value::as_array_mut) { + for item in items { + add_record_id(service, resource, item); + } + } + } +} + +fn add_record_id(service: &RelayService, resource: &CompiledResource, record: &mut Value) { + let Some(identifier) = record + .get("recordIdentifier") + .and_then(Value::as_str) + .map(str::to_owned) + else { + return; + }; + if let Some(object) = record.as_object_mut() { + object.insert( + "@id".into(), + Value::String(absolute( + &service.registry.base_uri, + &format!("/v2/resources/{}/records/{identifier}", resource.id), + )), + ); + } +} + +async fn release_document( + service: &RelayService, + audit: &AuditContext, + document: Value, + representation: Representation, + cacheable: bool, + headers: &HeaderMap, + trace: &TraceContext, +) -> Response { + let bytes = match bounded_json_bytes(&document, MAXIMUM_SERIALIZED_RESPONSE_BYTES) { + Ok(value) => value, + Err(_) => { + return terminal_problem( + &service.audit, + audit, + AuditOutcome::InternalFailed, + ProblemCode::Internal, + trace, + ) + .await + } + }; + let etag = cacheable.then(|| exact_etag(&bytes)); + if etag + .as_deref() + .is_some_and(|tag| if_none_match(headers, tag)) + { + if service + .audit + .terminal(audit, AuditOutcome::NotModified, None) + .await + .is_err() + { + return ProblemCode::AuditUnavailable.response(trace); + } + return not_modified(etag.as_deref().unwrap_or_default(), trace); + } + if service + .audit + .terminal(audit, AuditOutcome::Released, Some(&bytes)) + .await + .is_err() + { + return ProblemCode::AuditUnavailable.response(trace); + } + bytes_response( + bytes, + representation.media_type(), + cacheable, + etag.as_deref(), + trace, + ) +} + +struct BoundedWriter { + bytes: Vec, + maximum: usize, +} + +impl io::Write for BoundedWriter { + fn write(&mut self, buffer: &[u8]) -> io::Result { + let Some(length) = self.bytes.len().checked_add(buffer.len()) else { + return Err(io::Error::other("serialized response limit exceeded")); + }; + if length > self.maximum { + return Err(io::Error::other("serialized response limit exceeded")); + } + self.bytes.extend_from_slice(buffer); + Ok(buffer.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +fn bounded_json_bytes(document: &Value, maximum: usize) -> Result, ()> { + let mut writer = BoundedWriter { + bytes: Vec::new(), + maximum, + }; + serde_json::to_writer(&mut writer, document).map_err(|_| ())?; + Ok(writer.bytes) +} + +async fn source_failure( + audit: &RelayAudit, + context: &AuditContext, + error: SqliteRuntimeError, + trace: &TraceContext, +) -> Response { + let (outcome, code) = match error { + SqliteRuntimeError::AdmissionTimeout => (AuditOutcome::TimedOut, ProblemCode::Timeout), + SqliteRuntimeError::MissingSource + | SqliteRuntimeError::UnknownOperation + | SqliteRuntimeError::SchemaMismatch + | SqliteRuntimeError::InvalidPlan => (AuditOutcome::InternalFailed, ProblemCode::Internal), + SqliteRuntimeError::Source(_) => { + (AuditOutcome::SourceFailed, ProblemCode::SourceUnavailable) + } + }; + if audit.terminal(context, outcome, None).await.is_err() { + return ProblemCode::AuditUnavailable.response(trace); + } + code.response(trace) +} + +async fn terminal_problem( + audit: &RelayAudit, + context: &AuditContext, + outcome: AuditOutcome, + code: ProblemCode, + trace: &TraceContext, +) -> Response { + if audit.terminal(context, outcome, None).await.is_err() { + return ProblemCode::AuditUnavailable.response(trace); + } + code.response(trace) +} + +fn cacheable(operation: &CompiledOperation, source: &SourceRevision) -> bool { + matches!(operation.access, CompiledAccess::Public) + && matches!(source, SourceRevision::Snapshot(_)) +} + +fn negotiate(headers: &HeaderMap) -> Result { + let Some(value) = headers.get(ACCEPT) else { + return Ok(Representation::Json); + }; + let value = value + .to_str() + .map_err(|_| ProblemCode::UnsupportedRepresentation)?; + let mut json = false; + let mut json_ld = false; + for item in value.split(',') { + let mut parts = item.trim().split(';'); + let media = parts.next().unwrap_or_default().trim(); + let refused = parts.any(|parameter| parameter.trim() == "q=0"); + if refused { + continue; + } + match media { + "application/json" | "application/*" | "*/*" => json = true, + "application/ld+json" => json_ld = true, + _ => {} + } + } + if json_ld { + Ok(Representation::JsonLd) + } else if json { + Ok(Representation::Json) + } else { + Err(ProblemCode::UnsupportedRepresentation) + } +} + +fn rejects_caller_purpose(headers: &HeaderMap) -> bool { + headers.contains_key("purpose") || headers.contains_key("x-purpose") +} + +fn is_json_content_type(headers: &HeaderMap) -> bool { + headers + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.split(';').next()) + .is_some_and(|value| value.trim().eq_ignore_ascii_case("application/json")) +} + +fn next_cursor( + service: &RelayService, + operation: &CompiledOperation, + access: &Access, + query: &PreparedList, + last: &ResultRow, + source_revision: &SourceRevision, +) -> Result { + let key = service.cursor_key.as_ref().ok_or(())?; + let last_record_identifier = operation + .query + .order_by + .last() + .and_then(|column| last.get(column)) + .and_then(|value| match value { + SqlValue::String(value) => Some(value.clone()), + _ => None, + }) + .ok_or(())?; + let last_order_values = operation + .query + .order_by + .iter() + .map(|column| last.get(column).cloned().and_then(sql_to_cursor).ok_or(())) + .collect::, _>>()?; + let mut payload = cursor_template( + service, + operation, + access, + &query.filters, + &query.selected_fields, + &source_revision.cursor_value(), + ) + .map_err(|_| ())?; + payload.expires_at_unix_seconds = now_unix_seconds() + .checked_add(service.cursor_maximum_age.as_secs()) + .ok_or(())?; + payload.last_record_identifier = last_record_identifier; + payload.page_size = query.page_size; + payload.filters = query + .filters + .iter() + .map(|(name, value)| Ok((name.clone(), sql_to_cursor(value.clone()).ok_or(())?))) + .collect::>()?; + payload.selected_fields = query.selected_fields.clone(); + payload.last_order_values = last_order_values; + encode_cursor(key, &payload).map_err(|_| ()) +} + +fn cursor_template( + service: &RelayService, + operation: &CompiledOperation, + access: &Access, + filters: &BTreeMap, + selected_fields: &[String], + source_revision: &str, +) -> Result { + let key = service + .cursor_key + .as_ref() + .ok_or(ProblemCode::CursorInvalid)?; + let filter_json = serde_json::to_vec(filters).map_err(|_| ProblemCode::CursorInvalid)?; + let field_json = serde_json::to_vec(selected_fields).map_err(|_| ProblemCode::CursorInvalid)?; + let order_json = + serde_json::to_vec(&operation.query.order_by).map_err(|_| ProblemCode::CursorInvalid)?; + let authorization_material = access + .principal + .as_ref() + .map(|principal| principal.authorization_material(&operation.access, &access.authorization)) + .unwrap_or_else(|| b"anonymous".to_vec()); + Ok(CursorPayload::new( + u64::MAX, + service.registry.contract_revision.clone(), + source_revision.to_owned(), + operation.identifier.clone(), + CursorBindings { + filters_digest: key + .binding_digest(b"filters", &filter_json) + .map_err(|_| ProblemCode::CursorInvalid)?, + selected_fields_digest: key + .binding_digest(b"fields", &field_json) + .map_err(|_| ProblemCode::CursorInvalid)?, + authorization_digest: key + .binding_digest(b"authorization", &authorization_material) + .map_err(|_| ProblemCode::CursorInvalid)?, + order_digest: key + .binding_digest(b"order", &order_json) + .map_err(|_| ProblemCode::CursorInvalid)?, + last_record_identifier: String::new(), + }, + )) +} + +fn metadata_cursor_template( + service: &RelayService, + visible: &[(&CompiledResource, Vec<&CompiledOperation>)], +) -> Result { + let key = service + .cursor_key + .as_ref() + .ok_or(ProblemCode::CursorInvalid)?; + let authorization_material = visible + .iter() + .map(|(resource, operations)| { + ( + resource.id.as_str(), + operations + .iter() + .map(|operation| operation.identifier.as_str()) + .collect::>(), + ) + }) + .collect::>(); + let authorization_material = + serde_json::to_vec(&authorization_material).map_err(|_| ProblemCode::CursorInvalid)?; + Ok(CursorPayload::new( + u64::MAX, + service.registry.contract_revision.clone(), + format!("metadata:{}", service.registry.contract_revision), + "registry.resources".to_owned(), + CursorBindings { + filters_digest: key + .binding_digest(b"metadata-filters", b"none") + .map_err(|_| ProblemCode::CursorInvalid)?, + selected_fields_digest: key + .binding_digest(b"metadata-fields", b"fixed") + .map_err(|_| ProblemCode::CursorInvalid)?, + authorization_digest: key + .binding_digest(b"metadata-authorization", &authorization_material) + .map_err(|_| ProblemCode::CursorInvalid)?, + order_digest: key + .binding_digest(b"metadata-order", b"resourceIdentifier") + .map_err(|_| ProblemCode::CursorInvalid)?, + last_record_identifier: String::new(), + }, + )) +} + +fn metadata_next_cursor( + service: &RelayService, + visible: &[(&CompiledResource, Vec<&CompiledOperation>)], + page_size: usize, + last_resource_identifier: &str, +) -> Result { + let key = service + .cursor_key + .as_ref() + .ok_or(ProblemCode::CursorInvalid)?; + let mut payload = metadata_cursor_template(service, visible)?; + payload.expires_at_unix_seconds = now_unix_seconds() + .checked_add(service.cursor_maximum_age.as_secs()) + .ok_or(ProblemCode::CursorInvalid)?; + payload.page_size = u32::try_from(page_size).map_err(|_| ProblemCode::CursorInvalid)?; + payload.last_record_identifier = last_resource_identifier.to_owned(); + encode_cursor(key, &payload).map_err(|_| ProblemCode::CursorInvalid) +} + +fn sql_to_cursor(value: SqlValue) -> Option { + match value { + SqlValue::String(value) => Some(CursorValue::String(value)), + SqlValue::Integer(value) => Some(CursorValue::Integer(value)), + SqlValue::Boolean(value) => Some(CursorValue::Boolean(value)), + SqlValue::Null | SqlValue::Number(_) => None, + } +} + +fn cursor_to_sql(value: CursorValue) -> SqlValue { + match value { + CursorValue::String(value) => SqlValue::String(value), + CursorValue::Integer(value) => SqlValue::Integer(value), + CursorValue::Boolean(value) => SqlValue::Boolean(value), + } +} + +fn find_operation<'a>( + service: &'a RelayService, + resource_id: &str, + predicate: impl Fn(&OperationKind) -> bool, +) -> Option<(&'a CompiledResource, &'a CompiledOperation)> { + let resource = service + .registry + .resources + .iter() + .find(|resource| resource.id == resource_id)?; + let operation = resource + .operations + .iter() + .find(|operation| predicate(&operation.kind))?; + Some((resource, operation)) +} + +fn find_operation_by_id<'a>( + service: &'a RelayService, + identifier: &str, +) -> Option<&'a CompiledOperation> { + service + .registry + .resources + .iter() + .flat_map(|resource| resource.operations.iter()) + .find(|operation| operation.identifier == identifier) +} + +async fn visible_resources<'a>( + service: &'a RelayService, + principal: Option<&Principal>, +) -> Result)>, ProblemCode> { + if service.registry.metadata_visibility.resources == Visibility::OperatorOnly { + return Err(ProblemCode::ResourceNotFound); + } + if service.registry.metadata_visibility.resources == Visibility::OperationBound + && principal.is_none() + { + return Err(ProblemCode::MissingCredential); + } + let mut visible = Vec::new(); + for resource in &service.registry.resources { + let operations = visible_operations(service, resource, principal).await?; + if !operations.is_empty() { + visible.push((resource, operations)); + } + } + Ok(visible) +} + +async fn visible_operations<'a>( + service: &'a RelayService, + resource: &'a CompiledResource, + principal: Option<&Principal>, +) -> Result, ProblemCode> { + match service.registry.metadata_visibility.resources { + Visibility::OperatorOnly => Ok(Vec::new()), + Visibility::Public => Ok(resource + .operations + .iter() + .filter(|operation| matches!(operation.access, CompiledAccess::Public)) + .collect()), + Visibility::OperationBound => { + let principal = principal.ok_or(ProblemCode::MissingCredential)?; + let authenticator = service + .authenticator + .as_ref() + .ok_or(ProblemCode::ResourceNotFound)?; + Ok(resource + .operations + .iter() + .filter(|operation| { + authenticator + .authorize(&operation.access, Some(principal)) + .is_ok() + }) + .collect()) + } + } +} + +fn protected_metadata_exists(service: &RelayService) -> bool { + service.registry.metadata_visibility.resources == Visibility::OperationBound +} + +fn protected_artifact(artifact: &GeneratedArtifact) -> bool { + artifact.visibility == Visibility::OperationBound +} + +fn resource_document( + service: &RelayService, + resource: &CompiledResource, + operations: &[&CompiledOperation], +) -> Value { + let enumeration = if operations + .iter() + .any(|operation| matches!(operation.kind, OperationKind::List)) + { + if operations.iter().any(|operation| { + matches!(operation.kind, OperationKind::List) + && matches!(operation.access, CompiledAccess::Public) + }) { + "public" + } else { + "protected" + } + } else { + "none" + }; + json!({ + "resourceIdentifier": resource.id, + "title": resource.title, + "description": resource.description, + "semanticClass": resource.semantic_class, + "enumerationPosture": enumeration, + "capabilities": operations.iter().map(|operation| capability(service, resource, operation)).collect::>(), + "links": { + "self": absolute(&service.registry.base_uri, &format!("/v2/resources/{}", resource.id)), + } + }) +} + +fn capability( + service: &RelayService, + resource: &CompiledResource, + operation: &CompiledOperation, +) -> Value { + let mut document = json!({ + "family": "consultation", + "pattern": operation_pattern(&operation.kind), + "resourceIdentifier": resource.id, + "operationIdentifier": operation.identifier, + "schemaReference": operation.schema_reference, + "semanticModelReference": operation.semantic_model_reference, + "contextReference": operation.context_reference, + "href": match &operation.kind { + OperationKind::List => format!("/v2/resources/{}/records", resource.id), + OperationKind::Read => format!("/v2/resources/{}/records/{{recordIdentifier}}", resource.id), + OperationKind::Lookup {name} => format!("/v2/resources/{}/lookups/{name}", resource.id), + } + }); + let stem = operation_artifact_stem(&resource.id, &operation.kind); + let object = document + .as_object_mut() + .expect("capability document is an object"); + if service.registry.metadata_visibility.classifications != Visibility::OperatorOnly { + object.insert( + "classificationReference".into(), + Value::String(sibling_artifact_reference( + &operation.schema_reference, + &format!("{stem}-classifications"), + )), + ); + } + if service.registry.metadata_visibility.processing != Visibility::OperatorOnly { + object.insert( + "processingReference".into(), + Value::String(sibling_artifact_reference( + &operation.schema_reference, + &format!("{stem}-processing"), + )), + ); + } + document +} + +fn sibling_artifact_reference(reference: &str, artifact_identifier: &str) -> String { + reference.rsplit_once("/v2/artifacts/").map_or_else( + || format!("/v2/artifacts/{artifact_identifier}"), + |(origin, _)| format!("{origin}/v2/artifacts/{artifact_identifier}"), + ) +} + +fn operation_artifact_stem(resource: &str, kind: &OperationKind) -> String { + match kind { + OperationKind::List => format!("{resource}--list"), + OperationKind::Read => format!("{resource}--read"), + OperationKind::Lookup { name } => format!("{resource}--lookup-{name}"), + } +} + +fn operation_pattern(kind: &OperationKind) -> &'static str { + match kind { + OperationKind::List => "list", + OperationKind::Read => "retrieve", + OperationKind::Lookup { .. } => "search", + } +} + +fn operation_href( + service: &RelayService, + resource: &CompiledResource, + operation: &CompiledOperation, +) -> String { + let path = match &operation.kind { + OperationKind::List => format!("/v2/resources/{}/records", resource.id), + OperationKind::Read => { + format!("/v2/resources/{}/records/{{recordIdentifier}}", resource.id) + } + OperationKind::Lookup { name } => { + format!("/v2/resources/{}/lookups/{name}", resource.id) + } + }; + absolute(&service.registry.base_uri, &path) +} + +fn absolute(base: &str, path: &str) -> String { + let base = base.trim_end_matches('/'); + format!("{base}{path}") +} + +fn valid_record_identifier(value: &str) -> bool { + !value.is_empty() + && value.len() <= 512 + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~')) +} + +fn minimal_status(status: &'static str) -> Response { + let mut response = Response::new(Body::from(format!("{{\"status\":\"{status}\"}}"))); + response + .headers_mut() + .insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); + response + .headers_mut() + .insert(CACHE_CONTROL, HeaderValue::from_static("no-store")); + response +} + +fn json_metadata_response( + value: Value, + cacheable: bool, + headers: &HeaderMap, + trace: &TraceContext, +) -> Response { + let bytes = serde_json::to_vec(&value).unwrap_or_else(|_| b"{}".to_vec()); + static_bytes_response(&bytes, "application/json", cacheable, headers, trace) +} + +fn static_bytes_response( + bytes: &[u8], + media_type: &str, + cacheable: bool, + headers: &HeaderMap, + trace: &TraceContext, +) -> Response { + let etag = cacheable.then(|| exact_etag(bytes)); + if etag + .as_deref() + .is_some_and(|value| if_none_match(headers, value)) + { + return not_modified(etag.as_deref().unwrap_or_default(), trace); + } + bytes_response( + bytes.to_vec(), + media_type, + cacheable, + etag.as_deref(), + trace, + ) +} + +fn bytes_response( + bytes: Vec, + media_type: &str, + cacheable: bool, + etag: Option<&str>, + trace: &TraceContext, +) -> Response { + let mut response = Response::new(Body::from(bytes)); + if let Ok(content_type) = HeaderValue::from_str(media_type) { + response.headers_mut().insert(CONTENT_TYPE, content_type); + } + if cacheable { + response + .headers_mut() + .insert(CACHE_CONTROL, HeaderValue::from_static("public, no-cache")); + response + .headers_mut() + .insert(VARY, HeaderValue::from_static("Accept, Authorization")); + if let Some(etag) = etag.and_then(|value| HeaderValue::from_str(value).ok()) { + response.headers_mut().insert(ETAG, etag); + } + } else { + response + .headers_mut() + .insert(CACHE_CONTROL, HeaderValue::from_static("no-store")); + } + trace.apply(response.headers_mut()); + response +} + +fn not_modified(etag: &str, trace: &TraceContext) -> Response { + let mut response = Response::new(Body::empty()); + *response.status_mut() = StatusCode::NOT_MODIFIED; + response + .headers_mut() + .insert(CACHE_CONTROL, HeaderValue::from_static("public, no-cache")); + response + .headers_mut() + .insert(VARY, HeaderValue::from_static("Accept, Authorization")); + if let Ok(value) = HeaderValue::from_str(etag) { + response.headers_mut().insert(ETAG, value); + } + trace.apply(response.headers_mut()); + response +} + +fn exact_etag(bytes: &[u8]) -> String { + format!("\"{}\"", hex::encode(Sha256::digest(bytes))) +} + +fn if_none_match(headers: &HeaderMap, etag: &str) -> bool { + headers + .get(IF_NONE_MATCH) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.split(',').any(|item| item.trim() == etag)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn serialized_response_ceiling_counts_exact_json_bytes() { + let document = json!({"escaped": "\n\n\n\n"}); + let expected = serde_json::to_vec(&document).expect("JSON serializes"); + + assert_eq!( + bounded_json_bytes(&document, expected.len()).expect("exact bound is accepted"), + expected + ); + assert!(bounded_json_bytes(&document, expected.len().saturating_sub(1)).is_err()); + } +} diff --git a/crates/registry-relay-v2/src/artifacts.rs b/crates/registry-relay-v2/src/artifacts.rs new file mode 100644 index 000000000..a83acb742 --- /dev/null +++ b/crates/registry-relay-v2/src/artifacts.rs @@ -0,0 +1,973 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Repeatable artifacts generated only from the immutable compiled Registry. + +use std::collections::BTreeSet; + +use registry_platform_canonical_json::canonicalize_json; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Map, Value}; +use sha2::{Digest, Sha256}; +use thiserror::Error; + +use crate::contract::Visibility; +use crate::model::{CompiledAccess, CompiledRegistry, OperationKind}; +use crate::semantics::{ + full_record_schema, full_record_shacl, json_ld_context, local_vocabulary, + representation_schema, representation_shacl, +}; + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ArtifactSet { + pub contract_revision: String, + pub artifacts: Vec, + pub operation_bindings: Vec, +} + +impl ArtifactSet { + pub fn get(&self, path: &str) -> Option<&GeneratedArtifact> { + self.artifacts.iter().find(|artifact| artifact.path == path) + } +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct GeneratedArtifact { + pub id: String, + pub path: String, + pub media_type: String, + pub visibility: Visibility, + /// Present only for operation-bound artifacts. The HTTP layer must mount + /// the artifact behind this exact compiled operation's static access gate. + pub operation_identifier: Option, + pub sha256: String, + pub content: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct OperationArtifactBindings { + pub operation_identifier: String, + pub vocabulary_path: String, + pub context_path: String, + pub representation_schema_path: String, + pub representation_shacl_path: String, + pub classification_path: String, + pub processing_path: String, +} + +#[derive(Debug, Error)] +pub enum ArtifactError { + #[error("generated JSON could not be canonicalized")] + CanonicalJson, + #[error("a compiled operation refers to a missing disclosure profile")] + MissingDisclosure, +} + +pub fn generate_artifacts(registry: &CompiledRegistry) -> Result { + let mut artifacts = Vec::new(); + let mut bindings = Vec::new(); + + push_json( + &mut artifacts, + "openapi-full", + "openapi.full.yaml", + "application/yaml", + Visibility::OperatorOnly, + None, + &openapi(registry, false), + )?; + push_json( + &mut artifacts, + "openapi-public", + "openapi.public.json", + "application/json", + Visibility::Public, + None, + &openapi(registry, true), + )?; + push_json( + &mut artifacts, + "capability-inventory", + "artifacts/capabilities.json", + "application/json", + Visibility::Public, + None, + &capability_inventory(registry, CapabilityProjection::Public), + )?; + push_json( + &mut artifacts, + "capability-inventory-full", + "artifacts/capabilities.full.json", + "application/json", + Visibility::OperatorOnly, + None, + &capability_inventory(registry, CapabilityProjection::Full), + )?; + push_json( + &mut artifacts, + "audit-event-schema", + "artifacts/audit-event.schema.json", + "application/schema+json", + Visibility::OperatorOnly, + None, + &audit_event_schema(), + )?; + + for resource in ®istry.resources { + let all_properties = resource + .properties + .iter() + .map(|property| property.name.clone()) + .collect::>(); + push_json( + &mut artifacts, + &format!("{}-full-schema", resource.id), + &format!("artifacts/{}.full.schema.json", resource.id), + "application/schema+json", + Visibility::OperatorOnly, + None, + &full_record_schema(registry, resource), + )?; + push_text( + &mut artifacts, + &format!("{}-full-shacl", resource.id), + &format!("artifacts/{}.full.shacl.ttl", resource.id), + "text/turtle", + Visibility::OperatorOnly, + None, + full_record_shacl(registry, resource).into_bytes(), + ); + push_json( + &mut artifacts, + &format!("{}-full-vocabulary", resource.id), + &format!("artifacts/{}.full.vocabulary.jsonld", resource.id), + "application/ld+json", + Visibility::OperatorOnly, + None, + &local_vocabulary(registry, resource, &all_properties), + )?; + push_json( + &mut artifacts, + &format!("{}-classification", resource.id), + &format!("artifacts/{}.classifications.json", resource.id), + "application/json", + // This resource-wide inventory includes hidden source columns and + // every operation's properties. Only operation-specific safe + // projections may ever cross an operation gate. + Visibility::OperatorOnly, + None, + &json!({ + "resource": resource.id, + "properties": resource.properties.iter().map(|property| json!({ + "property": property.name, + "classification": property.classification, + })).collect::>(), + "columns": resource.column_accounting, + }), + )?; + push_json( + &mut artifacts, + &format!("{}-processing-full", resource.id), + &format!("artifacts/{}.processing.full.json", resource.id), + "application/json", + Visibility::OperatorOnly, + None, + &json!({ + "resourceIdentifier": resource.id, + "descriptions": resource.processing_descriptions, + }), + )?; + + for operation in &resource.operations { + if matches!(&operation.access, CompiledAccess::Protected { .. }) { + let visibility = Visibility::OperationBound; + push_json( + &mut artifacts, + &format!("{}-capability", operation.identifier), + &format!( + "artifacts/{}.capability.json", + operation_artifact_stem(&resource.id, &operation.kind) + ), + "application/json", + visibility, + (visibility == Visibility::OperationBound) + .then(|| operation.identifier.clone()), + &capability_inventory( + registry, + CapabilityProjection::Operation(&operation.identifier), + ), + )?; + } + let disclosure = resource + .disclosure_profiles + .iter() + .find(|profile| profile.id == operation.disclosure_profile) + .ok_or(ArtifactError::MissingDisclosure)?; + let semantic_visibility = + projection_visibility(registry.metadata_visibility.semantics, &operation.access); + let semantic_operation_identifier = (semantic_visibility == Visibility::OperationBound) + .then(|| operation.identifier.clone()); + let suffix = operation_artifact_stem(&resource.id, &operation.kind); + let vocabulary_path = format!("artifacts/{suffix}.vocabulary.jsonld"); + let context_path = format!("artifacts/{suffix}.context.jsonld"); + let schema_path = format!("artifacts/{suffix}.schema.json"); + let shacl_path = format!("artifacts/{suffix}.shacl.ttl"); + let classification_path = format!("artifacts/{suffix}.classifications.json"); + let processing_path = format!("artifacts/{suffix}.processing.json"); + push_json( + &mut artifacts, + &format!("{suffix}-vocabulary"), + &vocabulary_path, + "application/ld+json", + semantic_visibility, + semantic_operation_identifier.clone(), + &local_vocabulary(registry, resource, &disclosure.properties), + )?; + push_json( + &mut artifacts, + &format!("{suffix}-context"), + &context_path, + "application/ld+json", + semantic_visibility, + semantic_operation_identifier.clone(), + &json_ld_context(registry, resource, &disclosure.properties), + )?; + push_json( + &mut artifacts, + &format!("{suffix}-schema"), + &schema_path, + "application/schema+json", + semantic_visibility, + semantic_operation_identifier.clone(), + &representation_schema( + registry, + resource, + &disclosure.properties, + &operation.schema_reference, + &operation.semantic_model_reference, + ), + )?; + push_text( + &mut artifacts, + &format!("{suffix}-shacl"), + &shacl_path, + "text/turtle", + semantic_visibility, + semantic_operation_identifier, + representation_shacl(registry, resource, &disclosure.properties).into_bytes(), + ); + let classification_visibility = projection_visibility( + registry.metadata_visibility.classifications, + &operation.access, + ); + push_json( + &mut artifacts, + &format!("{suffix}-classifications"), + &classification_path, + "application/json", + classification_visibility, + (classification_visibility == Visibility::OperationBound) + .then(|| operation.identifier.clone()), + &json!({ + "resourceIdentifier": resource.id, + "operationIdentifier": operation.identifier, + "properties": resource.properties.iter() + .filter(|property| disclosure.properties.contains(&property.name)) + .map(|property| json!({ + "property": property.name, + "classification": property.classification, + })) + .collect::>(), + }), + )?; + let processing_visibility = + projection_visibility(registry.metadata_visibility.processing, &operation.access); + let operation_ref = operation_contract_reference(&operation.kind); + push_json( + &mut artifacts, + &format!("{suffix}-processing"), + &processing_path, + "application/json", + processing_visibility, + (processing_visibility == Visibility::OperationBound) + .then(|| operation.identifier.clone()), + &json!({ + "resourceIdentifier": resource.id, + "operationIdentifier": operation.identifier, + "descriptions": resource.processing_descriptions.iter() + .filter(|description| description.operation_refs.contains(&operation_ref)) + .collect::>(), + }), + )?; + bindings.push(OperationArtifactBindings { + operation_identifier: operation.identifier.clone(), + vocabulary_path, + context_path, + representation_schema_path: schema_path, + representation_shacl_path: shacl_path, + classification_path, + processing_path, + }); + } + + let codelists = resource + .properties + .iter() + .filter_map(|property| property.codelist.as_ref()) + .chain(std::iter::once( + &resource.record_context.lifecycle_state_codelist, + )) + .cloned() + .collect::>(); + for (index, codelist) in codelists.into_iter().enumerate() { + push_json( + &mut artifacts, + &format!("{}-codelist-{index}", resource.id), + &format!("artifacts/{}.codelist-{index}.schema.json", resource.id), + "application/schema+json", + Visibility::OperatorOnly, + None, + &json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Governed controlled-code source", + "type": "string", + "x-registry-codelist": codelist, + }), + )?; + } + } + + artifacts.sort_by(|left, right| left.path.cmp(&right.path)); + bindings.sort_by(|left, right| left.operation_identifier.cmp(&right.operation_identifier)); + Ok(ArtifactSet { + contract_revision: registry.contract_revision.clone(), + artifacts, + operation_bindings: bindings, + }) +} + +fn projection_visibility(configured: Visibility, access: &CompiledAccess) -> Visibility { + match configured { + Visibility::Public => Visibility::Public, + Visibility::OperatorOnly => Visibility::OperatorOnly, + Visibility::OperationBound => match access { + CompiledAccess::Public => Visibility::Public, + CompiledAccess::Protected { .. } => Visibility::OperationBound, + }, + } +} + +fn operation_contract_reference(kind: &OperationKind) -> String { + match kind { + OperationKind::List => "list".into(), + OperationKind::Read => "read".into(), + OperationKind::Lookup { name } => format!("lookup:{name}"), + } +} + +fn operation_artifact_stem(resource: &str, kind: &OperationKind) -> String { + match kind { + OperationKind::List => format!("{resource}--list"), + OperationKind::Read => format!("{resource}--read"), + OperationKind::Lookup { name } => format!("{resource}--lookup-{name}"), + } +} + +#[allow(clippy::too_many_arguments)] +fn push_json( + artifacts: &mut Vec, + id: &str, + path: &str, + media_type: &str, + visibility: Visibility, + operation_identifier: Option, + value: &Value, +) -> Result<(), ArtifactError> { + let bytes = canonicalize_json(value).map_err(|_| ArtifactError::CanonicalJson)?; + push_text( + artifacts, + id, + path, + media_type, + visibility, + operation_identifier, + bytes, + ); + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +fn push_text( + artifacts: &mut Vec, + id: &str, + path: &str, + media_type: &str, + visibility: Visibility, + operation_identifier: Option, + content: Vec, +) { + artifacts.push(GeneratedArtifact { + id: id.into(), + path: path.into(), + media_type: media_type.into(), + visibility, + operation_identifier, + sha256: format!("sha256:{}", hex::encode(Sha256::digest(&content))), + content, + }); +} + +fn openapi(registry: &CompiledRegistry, public_only: bool) -> Value { + let mut paths = Map::new(); + for (path, operation_id, description) in [ + ("/health", "relay.health", "Relay process liveness"), + ("/ready", "relay.ready", "Compiled Registry readiness"), + ( + "/openapi.json", + "relay.openapi.public", + "Safe public OpenAPI projection", + ), + ( + "/v2", + "relay.registry.metadata", + "Registry service metadata", + ), + ] { + paths.insert( + path.into(), + json!({"get": { + "operationId": operation_id, + "description": description, + "security": [], + "responses": {"200": {"description": "Successful response"}, "default": {"$ref": "#/components/responses/Problem"}} + }}), + ); + } + if !public_only || registry.metadata_visibility.resources == Visibility::Public { + paths.insert( + "/v2/resources".into(), + json!({"get": { + "operationId": "relay.resources.list", + "security": [], + "responses": {"200": {"description": "Visible Registry resources"}, "default": {"$ref": "#/components/responses/Problem"}} + }}), + ); + paths.insert( + "/v2/resources/{resource}".into(), + json!({"get": { + "operationId": "relay.resources.retrieve", + "security": if registry.metadata_visibility.resources == Visibility::Public { + json!([]) + } else { + json!([{"bearerAuth": []}]) + }, + "parameters": [{ + "name": "resource", "in": "path", "required": true, + "schema": {"type": "string", "minLength": 1} + }], + "responses": {"200": {"description": "Visible Registry resource metadata"}, "default": {"$ref": "#/components/responses/Problem"}} + }}), + ); + } + for resource in ®istry.resources { + for operation in &resource.operations { + if public_only && !matches!(operation.access, CompiledAccess::Public) { + continue; + } + let (method, path, pattern) = match &operation.kind { + OperationKind::List => ( + "get", + format!("/v2/resources/{}/records", resource.id), + "list", + ), + OperationKind::Read => ( + "get", + format!("/v2/resources/{}/records/{{recordIdentifier}}", resource.id), + "retrieve", + ), + OperationKind::Lookup { name } => ( + "post", + format!("/v2/resources/{}/lookups/{name}", resource.id), + "search", + ), + }; + let security = match &operation.access { + CompiledAccess::Public => json!([]), + CompiledAccess::Protected { .. } => { + json!([{"bearerAuth": []}]) + } + }; + let mut parameters = vec![json!({ + "name": "fields", + "in": "query", + "required": false, + "schema": {"type": "string", "minLength": 1}, + "description": "Duplicate-free comma-separated subset of the operation disclosure profile" + })]; + match &operation.kind { + OperationKind::List => { + let pagination = operation + .query + .pagination + .as_ref() + .expect("compiled list pagination"); + parameters.push(json!({"name": "pageSize", "in": "query", "required": false, "schema": {"type": "integer", "minimum": 1, "maximum": pagination.maximum_page_size, "default": pagination.default_page_size}})); + parameters.push(json!({"name": "cursor", "in": "query", "required": false, "schema": {"type": "string", "minLength": 1}})); + for filter in &operation.query.filters { + parameters.push(json!({ + "name": filter.parameter, + "in": "query", + "required": false, + "schema": openapi_type(filter.data_type), + "x-registry-exact-equality": true, + })); + } + } + OperationKind::Read => parameters.push(json!({ + "name": "recordIdentifier", "in": "path", "required": true, + "schema": {"type": "string", "minLength": 1} + })), + OperationKind::Lookup { .. } => {} + } + let mut operation_value = json!({ + "operationId": operation.identifier, + "x-registry-family": "consultation", + "x-registry-pattern": pattern, + "x-registry-disclosure-profile": operation.disclosure_profile, + "security": security, + "parameters": parameters, + "responses": { + "200": { + "description": "A validated minimum-disclosure Registry response", + "content": { + "application/json": {"schema": operation_response_schema(operation)}, + "application/ld+json": {"schema": operation_response_schema(operation)} + } + }, + "default": {"$ref": "#/components/responses/Problem"} + } + }); + if let CompiledAccess::Protected { scope, .. } = &operation.access { + operation_value + .as_object_mut() + .expect("operation object") + .insert("x-registry-required-scope".into(), json!(scope)); + } + if matches!(&operation.kind, OperationKind::Lookup { .. }) { + let mut selector_properties = Map::new(); + for selector in &operation.query.selectors { + let mut schema = openapi_type(selector.data_type); + if let Value::Object(schema) = &mut schema { + if let Some(minimum) = selector.minimum_bytes { + schema.insert("minLength".into(), json!(minimum)); + } + if let Some(maximum) = selector.maximum_bytes { + schema.insert("maxLength".into(), json!(maximum)); + } + } + selector_properties.insert(selector.name.clone(), schema); + } + operation_value + .as_object_mut() + .expect("operation object") + .insert( + "requestBody".into(), + json!({ + "required": true, + "content": {"application/json": {"schema": { + "type": "object", + "additionalProperties": false, + "required": operation.query.selectors.iter().map(|selector| selector.name.clone()).collect::>(), + "properties": selector_properties, + }}} + }), + ); + } + paths + .entry(path) + .or_insert_with(|| Value::Object(Map::new())) + .as_object_mut() + .expect("path item object") + .insert(method.into(), operation_value); + } + } + paths.insert( + "/v2/artifacts/{artifactIdentifier}".into(), + json!({"get": { + "operationId": "relay.artifacts.retrieve", + "description": "Retrieve a visibility-appropriate generated Registry artifact", + "security": [{}, {"bearerAuth": []}], + "parameters": [{ + "name": "artifactIdentifier", "in": "path", "required": true, + "schema": {"type": "string", "minLength": 1} + }], + "responses": {"200": {"description": "Generated artifact"}, "default": {"$ref": "#/components/responses/Problem"}} + }}), + ); + json!({ + "openapi": "3.1.0", + "info": { + "title": registry.registry_name, + "version": registry.contract_version, + "description": "Generated Registry Relay Consultation API. This document makes no conformance or certification claim." + }, + "servers": [{"url": registry.base_uri}], + "paths": paths, + "components": { + "securitySchemes": { + "bearerAuth": {"type": "http", "scheme": "bearer", "bearerFormat": "JWT"} + }, + "schemas": { + "Problem": { + "type": "object", "additionalProperties": false, + "required": ["type", "title", "status", "code", "traceId"], + "properties": { + "type": {"type": "string", "format": "uri"}, + "title": {"type": "string"}, + "status": {"type": "integer"}, + "detail": {"type": "string"}, + "code": {"type": "string"}, + "traceId": {"type": "string", "pattern": "^[0-9a-f]{32}$"} + } + } + }, + "responses": { + "Problem": { + "description": "Registry Stack problem", + "content": {"application/problem+json": {"schema": {"$ref": "#/components/schemas/Problem"}}} + } + } + } + }) +} + +fn operation_response_schema(operation: &crate::model::CompiledOperation) -> Value { + let meta = json!({"type": "object"}); + match &operation.kind { + OperationKind::List => json!({ + "type": "object", "additionalProperties": false, + "required": ["items", "pageInfo", "meta"], + "properties": { + "items": {"type": "array", "items": {"$ref": operation.schema_reference}}, + "pageInfo": { + "type": "object", "additionalProperties": false, + "required": ["nextCursor"], + "properties": {"nextCursor": {"type": ["string", "null"]}} + }, + "meta": meta + } + }), + OperationKind::Read | OperationKind::Lookup { .. } => json!({ + "type": "object", "additionalProperties": false, + "required": ["data", "meta"], + "properties": { + "data": {"$ref": operation.schema_reference}, + "meta": meta + } + }), + } +} + +fn openapi_type(data_type: crate::contract::DataType) -> Value { + use crate::contract::DataType; + match data_type { + DataType::String | DataType::ControlledCode => json!({"type": "string"}), + DataType::Boolean => json!({"type": "boolean"}), + DataType::Integer => json!({"type": "integer"}), + DataType::Date => json!({"type": "string", "format": "date"}), + DataType::DateTime => json!({"type": "string", "format": "date-time"}), + } +} + +#[derive(Clone, Copy)] +enum CapabilityProjection<'a> { + Public, + Full, + Operation(&'a str), +} + +fn capability_inventory( + registry: &CompiledRegistry, + projection: CapabilityProjection<'_>, +) -> Value { + let capabilities = registry + .resources + .iter() + .flat_map(|resource| { + resource.operations.iter().filter_map(move |operation| { + let include = match projection { + CapabilityProjection::Public => { + matches!(&operation.access, CompiledAccess::Public) + } + CapabilityProjection::Full => true, + CapabilityProjection::Operation(identifier) => { + operation.identifier == identifier + } + }; + if !include { + return None; + } + let pattern = match &operation.kind { + OperationKind::List => "list", + OperationKind::Read => "retrieve", + OperationKind::Lookup { .. } => "search", + }; + Some(json!({ + "resource": resource.id, + "operationIdentifier": operation.identifier, + "family": "consultation", + "pattern": pattern, + "profile": if matches!(&operation.kind, OperationKind::Lookup { .. }) { Value::String("exact".into()) } else { Value::Null }, + "schemaReference": operation.schema_reference, + "semanticModelReference": operation.semantic_model_reference, + "contextReference": operation.context_reference, + })) + }) + }) + .collect::>(); + json!({ + "registryIdentifier": registry.registry_identifier, + "authorityIdentifier": registry.authority_identifier, + "contractRevision": registry.contract_revision, + "apiBinding": {"name": "registry-relay", "version": "v2alpha1"}, + "alignmentTargets": registry.alignment_targets, + "metadataVisibility": registry.metadata_visibility, + "capabilities": capabilities, + "unsupportedFamilies": ["provisioning", "evidence", "write", "notification", "aggregate-data", "access-transparency", "identity-federation"] + }) +} + +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", + "title": "Registry Relay value-free consultation audit event", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", "phase", "operationId", "traceId", "registryIdentifier", + "rowBoundaryKind", "processingDescriptionIdentifiers", "selectedProperties", + "contractRevision", "principalKind" + ], + "properties": { + "schema": {"const": crate::audit::AUDIT_SCHEMA}, + "phase": {"enum": ["attempt", "refusal", "terminal"]}, + "operationId": {"type": "string", "minLength": 1}, + "traceId": {"type": "string", "pattern": "^[0-9a-f]{32}$"}, + "registryIdentifier": {"type": "string", "minLength": 1}, + "resourceIdentifier": {"type": "string", "minLength": 1}, + "operationIdentifier": {"type": "string", "minLength": 1}, + "accessRuleRevision": {"type": "string", "minLength": 1}, + "purpose": {"type": "string", "minLength": 1}, + "rowBoundaryKind": {"enum": ["none", "principal", "verified-claim", "unknown"]}, + "disclosureProfile": {"type": "string", "minLength": 1}, + "processingDescriptionIdentifiers": {"type": "array", "items": {"type": "string", "minLength": 1}, "uniqueItems": true}, + "selectedProperties": {"type": "array", "items": {"type": "string", "minLength": 1}, "uniqueItems": true}, + "maximumHandling": {"enum": ["public", "internal", "confidential", "restricted"]}, + "contractRevision": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, + "sourceRevision": { + "type": "object", + "additionalProperties": false, + "required": ["profile", "status", "value"], + "properties": { + "profile": {"enum": ["snapshot", "live"]}, + "status": {"enum": ["versioned", "unversioned"]}, + "value": {"type": ["string", "null"]} + } + }, + "principalKind": {"enum": ["anonymous", "authenticated", "unknown"]}, + "outcome": {"enum": [ + "released", "not-modified", "unresolved", "invalid-request", + "missing-credential", "invalid-credential", "denied", "rate-limited", + "timed-out", "source-failed", "internal-failed", "not-found" + ]} + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::compiler::{compile_contract_with_governed_files, tests as compiler_tests}; + use crate::contract::RegistryContract; + use crate::model::CompileProfile; + + #[test] + fn generated_inventory_covers_required_v1_artifact_classes_only() { + let contract = RegistryContract::parse_yaml(compiler_tests::valid_contract()) + .expect("contract parses"); + let registry = compile_contract_with_governed_files( + &contract, + &[compiler_tests::observed_schema()], + CompileProfile::Production, + &compiler_tests::governed_files(), + ) + .expect("contract compiles"); + let generated = generate_artifacts(®istry).expect("artifacts generate"); + let paths = generated + .artifacts + .iter() + .map(|artifact| artifact.path.as_str()) + .collect::>(); + + for required in [ + "openapi.full.yaml", + "openapi.public.json", + "artifacts/audit-event.schema.json", + "artifacts/capabilities.json", + "artifacts/capabilities.full.json", + "artifacts/record.full.schema.json", + "artifacts/record.full.shacl.ttl", + "artifacts/record.full.vocabulary.jsonld", + "artifacts/record--read.schema.json", + "artifacts/record--read.shacl.ttl", + "artifacts/record--read.context.jsonld", + "artifacts/record--read.vocabulary.jsonld", + ] { + assert!(paths.contains(required), "missing {required}"); + } + for deferred in [ + "artifacts/registry-manifest.yaml", + "artifacts/standards-alignment.json", + "artifacts/safeguards-matrix.yaml", + ] { + assert!(!paths.contains(deferred), "deferred artifact {deferred}"); + } + } + + #[test] + fn generated_openapi_covers_router_paths_and_public_is_a_full_subset() { + let contract = RegistryContract::parse_yaml(compiler_tests::valid_contract()) + .expect("contract parses"); + let registry = compile_contract_with_governed_files( + &contract, + &[compiler_tests::observed_schema()], + CompileProfile::Production, + &compiler_tests::governed_files(), + ) + .expect("contract compiles"); + let generated = generate_artifacts(®istry).expect("artifacts generate"); + let full: Value = serde_json::from_slice( + &generated + .get("openapi.full.yaml") + .expect("full OpenAPI") + .content, + ) + .expect("full OpenAPI is JSON-compatible YAML"); + let public: Value = serde_json::from_slice( + &generated + .get("openapi.public.json") + .expect("public OpenAPI") + .content, + ) + .expect("public OpenAPI JSON"); + serde_json::from_slice::( + &generated + .get("openapi.full.yaml") + .expect("full OpenAPI") + .content, + ) + .expect("full OpenAPI conforms to the maintained OpenAPI model"); + serde_json::from_slice::( + &generated + .get("openapi.public.json") + .expect("public OpenAPI") + .content, + ) + .expect("public OpenAPI conforms to the maintained OpenAPI model"); + + for path in [ + "/health", + "/ready", + "/openapi.json", + "/v2", + "/v2/resources", + "/v2/resources/{resource}", + "/v2/resources/record/records/{recordIdentifier}", + "/v2/artifacts/{artifactIdentifier}", + ] { + assert!(full["paths"].get(path).is_some(), "missing {path}"); + } + for (path, definition) in public["paths"].as_object().expect("public paths") { + assert_eq!( + full["paths"].get(path), + Some(definition), + "public path {path} must be byte-semantically identical in full OpenAPI" + ); + } + assert_eq!( + full["components"]["securitySchemes"]["bearerAuth"]["type"], + "http" + ); + assert!(full["components"]["securitySchemes"] + .get("oauth2") + .is_none()); + } + + #[test] + fn configured_metadata_visibility_drives_safe_operation_projections() { + let contract = RegistryContract::parse_yaml(compiler_tests::valid_contract()) + .expect("contract parses"); + let mut registry = compile_contract_with_governed_files( + &contract, + &[compiler_tests::observed_schema()], + CompileProfile::Production, + &compiler_tests::governed_files(), + ) + .expect("contract compiles"); + registry.resources[0].operations[0].access = CompiledAccess::Protected { + scope: "records:read".into(), + purpose: None, + row_binding: None, + }; + registry.metadata_visibility.semantics = Visibility::OperationBound; + registry.metadata_visibility.classifications = Visibility::OperationBound; + registry.metadata_visibility.processing = Visibility::OperationBound; + let generated = generate_artifacts(®istry).expect("artifacts generate"); + for id in [ + "record--read-vocabulary", + "record--read-context", + "record--read-schema", + "record--read-shacl", + "record--read-classifications", + "record--read-processing", + ] { + let artifact = generated + .artifacts + .iter() + .find(|artifact| artifact.id == id) + .unwrap_or_else(|| panic!("missing {id}")); + assert_eq!(artifact.visibility, Visibility::OperationBound); + assert_eq!( + artifact.operation_identifier.as_deref(), + Some("record.read") + ); + } + + registry.metadata_visibility.semantics = Visibility::Public; + registry.metadata_visibility.classifications = Visibility::OperatorOnly; + registry.metadata_visibility.processing = Visibility::OperatorOnly; + let generated = generate_artifacts(®istry).expect("artifacts generate"); + assert_eq!( + generated + .artifacts + .iter() + .find(|artifact| artifact.id == "record--read-vocabulary") + .expect("semantic projection") + .visibility, + Visibility::Public + ); + for id in ["record--read-classifications", "record--read-processing"] { + assert_eq!( + generated + .artifacts + .iter() + .find(|artifact| artifact.id == id) + .unwrap_or_else(|| panic!("missing {id}")) + .visibility, + Visibility::OperatorOnly + ); + } + } +} diff --git a/crates/registry-relay-v2/src/audit.rs b/crates/registry-relay-v2/src/audit.rs new file mode 100644 index 000000000..6927c03ed --- /dev/null +++ b/crates/registry-relay-v2/src/audit.rs @@ -0,0 +1,235 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Relay V2's closed, value-free audit event vocabulary and release gate. + +use std::sync::Arc; +use std::{future::Future, pin::Pin}; + +use registry_platform_audit::{AuditError, AuditSink, ChainState}; +use serde::Serialize; +use serde_json::Value; +use ulid::Ulid; + +use crate::problem::TraceId; +use crate::sqlite_runtime::SourceRevision; + +pub const AUDIT_SCHEMA: &str = "registry.relay.consultation-audit/v2alpha1"; + +#[derive(Clone)] +pub struct RelayAudit { + chain: Arc, + sink: Arc, + readiness: AuditReadiness, +} + +type AuditReadiness = Arc Pin + Send>> + Send + Sync>; + +impl RelayAudit { + #[must_use] + pub fn new(chain: Arc, sink: Arc) -> Self { + let observed_chain = Arc::clone(&chain); + Self { + chain, + sink, + readiness: Arc::new(move || { + let ready = observed_chain.try_last_hash().is_some(); + Box::pin(async move { ready }) + }), + } + } + + /// Install an async, value-free concrete sink probe. Production startup + /// uses this with the keyed sink verifier it already owns, allowing + /// readiness to detect an unavailable or replaced audit destination. + #[must_use] + pub fn with_readiness_check(mut self, check: F) -> Self + where + F: Fn() -> Fut + Send + Sync + 'static, + Fut: Future + Send + 'static, + { + self.readiness = Arc::new(move || Box::pin(check())); + self + } + + #[must_use] + pub fn operation_id() -> String { + Ulid::new().to_string() + } + + pub async fn attempt(&self, context: &AuditContext) -> Result<(), AuditError> { + self.append(AuditEvent::from_context(context, AuditPhase::Attempt, None)) + .await + } + + pub async fn refusal( + &self, + context: &AuditContext, + outcome: AuditOutcome, + ) -> Result<(), AuditError> { + self.append(AuditEvent::from_context( + context, + AuditPhase::Refusal, + Some(outcome), + )) + .await + } + + pub async fn terminal( + &self, + context: &AuditContext, + outcome: AuditOutcome, + _exact_response_bytes: Option<&[u8]>, + ) -> Result<(), AuditError> { + self.append(AuditEvent::from_context( + context, + AuditPhase::Terminal, + Some(outcome), + )) + .await + } + + async fn append(&self, event: AuditEvent) -> Result<(), AuditError> { + self.chain.append(self.sink.as_ref(), event).await?; + Ok(()) + } + + #[must_use] + pub async fn ready(&self) -> bool { + self.chain.try_last_hash().is_some() && (self.readiness)().await + } +} + +#[derive(Clone, Debug)] +pub struct AuditContext { + pub operation_id: String, + pub trace_id: TraceId, + pub registry_identifier: String, + pub resource_identifier: Option, + pub operation_identifier: Option, + pub access_rule_revision: Option, + pub purpose: Option, + pub row_boundary_kind: RowBoundaryKind, + pub disclosure_profile: Option, + pub processing_description_identifiers: Vec, + pub selected_properties: Vec, + pub maximum_handling: Option, + pub contract_revision: String, + pub source_revision: Option, + pub principal_kind: PrincipalKind, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum PrincipalKind { + Anonymous, + Authenticated, + Unknown, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum RowBoundaryKind { + None, + Principal, + VerifiedClaim, + Unknown, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum AuditOutcome { + Released, + NotModified, + Unresolved, + InvalidRequest, + MissingCredential, + InvalidCredential, + Denied, + RateLimited, + TimedOut, + SourceFailed, + InternalFailed, + NotFound, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "kebab-case")] +enum AuditPhase { + Attempt, + Refusal, + Terminal, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct AuditEvent { + schema: &'static str, + phase: AuditPhase, + operation_id: String, + trace_id: String, + registry_identifier: String, + #[serde(skip_serializing_if = "Option::is_none")] + resource_identifier: Option, + #[serde(skip_serializing_if = "Option::is_none")] + operation_identifier: Option, + #[serde(skip_serializing_if = "Option::is_none")] + access_rule_revision: Option, + #[serde(skip_serializing_if = "Option::is_none")] + purpose: Option, + row_boundary_kind: RowBoundaryKind, + #[serde(skip_serializing_if = "Option::is_none")] + disclosure_profile: Option, + processing_description_identifiers: Vec, + selected_properties: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + maximum_handling: Option, + contract_revision: String, + #[serde(skip_serializing_if = "Option::is_none")] + source_revision: Option, + principal_kind: PrincipalKind, + #[serde(skip_serializing_if = "Option::is_none")] + outcome: Option, +} + +impl AuditEvent { + fn from_context( + context: &AuditContext, + phase: AuditPhase, + outcome: Option, + ) -> Self { + Self { + schema: AUDIT_SCHEMA, + phase, + operation_id: context.operation_id.clone(), + trace_id: context.trace_id.as_str().to_owned(), + registry_identifier: context.registry_identifier.clone(), + resource_identifier: context.resource_identifier.clone(), + operation_identifier: context.operation_identifier.clone(), + access_rule_revision: context.access_rule_revision.clone(), + purpose: context.purpose.clone(), + row_boundary_kind: context.row_boundary_kind, + disclosure_profile: context.disclosure_profile.clone(), + processing_description_identifiers: context.processing_description_identifiers.clone(), + selected_properties: context.selected_properties.clone(), + maximum_handling: context.maximum_handling.clone(), + contract_revision: context.contract_revision.clone(), + source_revision: context.source_revision.as_ref().map(source_revision), + principal_kind: context.principal_kind, + outcome, + } + } +} + +fn source_revision(revision: &SourceRevision) -> Value { + match revision { + SourceRevision::Snapshot(value) => serde_json::json!({ + "profile": "snapshot", + "status": "versioned", + "value": value, + }), + SourceRevision::LiveUnversioned => serde_json::json!({ + "profile": "live", + "status": "unversioned", + "value": null, + }), + } +} diff --git a/crates/registry-relay-v2/src/auth.rs b/crates/registry-relay-v2/src/auth.rs new file mode 100644 index 000000000..4f32f7beb --- /dev/null +++ b/crates/registry-relay-v2/src/auth.rs @@ -0,0 +1,609 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Relay V2 access-token authentication and compiled-operation authorization. + +use std::collections::BTreeSet; +use std::fmt; +use std::sync::Arc; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine as _; +use http::header::AUTHORIZATION; +use http::HeaderMap; +use registry_platform_oidc::{Audience, TokenVerifier, VerifiedToken}; +use serde::de::{self, DeserializeSeed as _, Visitor}; +use serde_json::{Map, Value}; +use thiserror::Error; + +#[cfg(feature = "tooling")] +use std::collections::BTreeMap; + +use crate::model::{CompiledAccess, RowAuthoritySource}; + +const MAX_TOKEN_BYTES: usize = 128 * 1024; +const MAX_TOKEN_SEGMENT_BYTES: usize = 64 * 1024; +const MAX_DIRECT_CLAIM_BYTES: usize = 512; + +/// Verified caller context. Its `Debug` implementation deliberately redacts +/// authority-bearing values. +#[derive(Clone)] +pub struct Principal { + identifier: String, + scopes: BTreeSet, + claims: Value, +} + +impl Principal { + #[must_use] + pub fn identifier(&self) -> &str { + &self.identifier + } + + #[must_use] + pub fn has_scope(&self, scope: &str) -> bool { + self.scopes.contains(scope) + } + + fn required_direct_string(&self, name: &str) -> Result<&str, AuthenticationError> { + direct_string(self.claims.as_object(), name).ok_or(AuthenticationError::Claims) + } + + pub(crate) fn authorization_material( + &self, + access: &CompiledAccess, + authorization: &Authorization, + ) -> Vec { + let mut material = Vec::new(); + material.extend_from_slice(b"registry-relay-v2-authorization-context-v1\0"); + material.extend_from_slice(self.identifier.as_bytes()); + if let CompiledAccess::Protected { scope, purpose, .. } = access { + material.push(0); + material.extend_from_slice(scope.as_bytes()); + if let Some(purpose) = purpose { + material.push(0); + if let Ok(value) = self.required_direct_string(&purpose.claim) { + material.extend_from_slice(value.as_bytes()); + } + } + } + if let Some(row) = &authorization.row_authority { + material.push(0); + material.extend_from_slice(row.source_column.as_bytes()); + material.push(0); + material.extend_from_slice(row.value.as_bytes()); + } + material + } +} + +impl fmt::Debug for Principal { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("Principal") + .field("identifier", &"") + .field("scopes", &self.scopes) + .field("claims", &"") + .finish() + } +} + +/// A bound authority value injected by Relay into a reviewed SQL plan. +#[derive(Clone, PartialEq, Eq)] +pub struct RowAuthority { + pub source_column: String, + pub value: String, +} + +impl fmt::Debug for RowAuthority { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RowAuthority") + .field("source_column", &self.source_column) + .field("value", &"") + .finish() + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Authorization { + pub row_authority: Option, + pub purpose: Option, +} + +#[derive(Clone)] +pub struct RelayAuthenticator { + verifier: Option>, + expected_audience: Option, + clock_leeway: Duration, + #[cfg(feature = "tooling")] + fixtures: BTreeMap, +} + +impl RelayAuthenticator { + #[must_use] + pub fn new( + verifier: Arc, + expected_audience: String, + clock_leeway: Duration, + ) -> Self { + Self { + verifier: Some(verifier), + expected_audience: Some(expected_audience), + clock_leeway, + #[cfg(feature = "tooling")] + fixtures: BTreeMap::new(), + } + } + + /// Authoring-tool-only real-router verifier seam. It supplies explicit + /// synthetic fixture claims to the same principal resolution and + /// authorization path. Runtime configuration cannot construct this mode. + #[cfg(feature = "tooling")] + #[must_use] + pub(crate) fn for_offline_fixtures(tokens: BTreeMap) -> Self { + let fixtures = tokens + .into_iter() + .map(|(token, item)| { + ( + token, + Principal { + identifier: item.identifier, + scopes: item.scopes, + claims: item.claims, + }, + ) + }) + .collect(); + Self { + verifier: None, + expected_audience: None, + clock_leeway: Duration::ZERO, + fixtures, + } + } + + /// Authenticate one already-extracted bearer token. The platform verifier + /// enforces signature, issuer, audience, algorithm, key id, token type, + /// expiration, not-before, and scopes. Relay adds bounded token shape, + /// issued-at, token-id, and principal selection rules. + pub async fn authenticate(&self, token: &str) -> Result { + strict_token_shape(token)?; + #[cfg(feature = "tooling")] + if let Some(principal) = self.fixtures.get(token) { + return Ok(principal.clone()); + } + let verified = self + .verifier + .as_ref() + .ok_or(AuthenticationError::Verification)? + .verify(token) + .await + .map_err(|_| AuthenticationError::Verification)?; + Principal::from_verified( + verified, + self.expected_audience + .as_deref() + .ok_or(AuthenticationError::Verification)?, + self.clock_leeway, + ) + } + + /// Confirm the configured issuer key source can still verify tokens. + /// Offline fixture authentication is already fully in memory and can only + /// be constructed by crate-internal authoring tooling. + pub async fn is_ready(&self) -> bool { + match &self.verifier { + Some(verifier) => verifier.key_source().ensure_key_set().await.is_ok(), + None => true, + } + } + + /// Enforce the one compiled access rule. Caller query values and headers + /// are deliberately absent from this function: only verified token claims + /// can satisfy purpose and row-binding constraints. + pub fn authorize( + &self, + access: &CompiledAccess, + principal: Option<&Principal>, + ) -> Result { + match access { + CompiledAccess::Public => Ok(Authorization { + row_authority: None, + purpose: None, + }), + CompiledAccess::Protected { + scope, + purpose, + row_binding, + } => { + let principal = principal.ok_or(AuthorizationError::AuthenticationRequired)?; + if !principal.has_scope(scope) { + return Err(AuthorizationError::ScopeDenied); + } + let authorized_purpose = if let Some(purpose) = purpose { + let value = principal + .required_direct_string(&purpose.claim) + .map_err(|_| AuthorizationError::PurposeDenied)?; + if !purpose.allowed.iter().any(|allowed| allowed == value) { + return Err(AuthorizationError::PurposeDenied); + } + Some(value.to_owned()) + } else { + None + }; + let row_authority = row_binding + .as_ref() + .map(|binding| { + let value = match &binding.source { + RowAuthoritySource::Principal => principal.identifier().to_owned(), + RowAuthoritySource::Claim(claim) => principal + .required_direct_string(claim) + .map_err(|_| AuthorizationError::BindingDenied)? + .to_owned(), + }; + Ok(RowAuthority { + source_column: binding.source_column.clone(), + value, + }) + }) + .transpose()?; + Ok(Authorization { + row_authority, + purpose: authorized_purpose, + }) + } + } + } +} + +#[derive(Clone, Debug)] +#[cfg(feature = "tooling")] +pub(crate) struct FixturePrincipal { + pub identifier: String, + pub scopes: BTreeSet, + pub claims: Value, +} + +impl Principal { + fn from_verified( + verified: VerifiedToken, + expected_audience: &str, + clock_leeway: Duration, + ) -> Result { + let VerifiedToken { claims, scopes, .. } = verified; + if !matches!( + claims.aud.as_ref(), + Some(Audience::One(audience)) if audience == expected_audience + ) { + return Err(AuthenticationError::Claims); + } + let claims = serde_json::to_value(claims).map_err(|_| AuthenticationError::Claims)?; + let object = claims.as_object().ok_or(AuthenticationError::Claims)?; + let issued_at = object + .get("iat") + .and_then(Value::as_i64) + .filter(|value| *value > 0) + .ok_or(AuthenticationError::Claims)?; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .ok() + .and_then(|duration| i64::try_from(duration.as_secs()).ok()) + .ok_or(AuthenticationError::Claims)?; + let leeway = i64::try_from(clock_leeway.as_secs()).unwrap_or(i64::MAX); + if issued_at > now.saturating_add(leeway) { + return Err(AuthenticationError::Claims); + } + let not_before = object + .get("nbf") + .and_then(Value::as_i64) + .filter(|value| *value > 0) + .ok_or(AuthenticationError::Claims)?; + let _ = not_before; + let _jti = direct_string(Some(object), "jti").ok_or(AuthenticationError::Claims)?; + let identifier = strict_principal_identifier(object)?; + let scopes = scopes.into_iter().collect(); + Ok(Self { + identifier, + scopes, + claims, + }) + } +} + +/// Resolve a principal without silently skipping a malformed higher-priority +/// identity claim. This prevents an issuer's `sub` from being bypassed by an +/// attacker-controlled fallback claim. +fn strict_principal_identifier(claims: &Map) -> Result { + for name in ["sub", "client_id", "azp"] { + if let Some(value) = claims.get(name) { + return direct_string_value(value) + .map(str::to_owned) + .ok_or(AuthenticationError::Claims); + } + } + Err(AuthenticationError::Claims) +} + +#[derive(Clone, Copy, Debug, Error, PartialEq, Eq)] +pub enum AuthenticationError { + #[error("access token is malformed")] + Malformed, + #[error("access token verification failed")] + Verification, + #[error("required access token claim is invalid")] + Claims, +} + +#[derive(Clone, Copy, Debug, Error, PartialEq, Eq)] +pub enum AuthorizationError { + #[error("authentication is required")] + AuthenticationRequired, + #[error("required scope is absent")] + ScopeDenied, + #[error("required purpose is absent")] + PurposeDenied, + #[error("authority binding is absent")] + BindingDenied, +} + +/// Extract exactly one RFC 6750 Bearer credential. An invalid Authorization +/// header is never interpreted as anonymous access, including on public +/// operations and cacheable metadata routes. +pub fn bearer_token(headers: &HeaderMap) -> Result, AuthenticationError> { + let mut values = headers.get_all(AUTHORIZATION).iter(); + let Some(first) = values.next() else { + return Ok(None); + }; + if values.next().is_some() { + return Err(AuthenticationError::Malformed); + } + let value = first.to_str().map_err(|_| AuthenticationError::Malformed)?; + let token = value + .strip_prefix("Bearer ") + .ok_or(AuthenticationError::Malformed)?; + if token.is_empty() || token.bytes().any(|byte| byte.is_ascii_whitespace()) { + return Err(AuthenticationError::Malformed); + } + Ok(Some(token)) +} + +fn strict_token_shape(token: &str) -> Result<(), AuthenticationError> { + if token.is_empty() + || token.len() > MAX_TOKEN_BYTES + || token.bytes().any(|byte| byte.is_ascii_whitespace()) + { + return Err(AuthenticationError::Malformed); + } + let mut segments = token.split('.'); + let header = segments.next().ok_or(AuthenticationError::Malformed)?; + let claims = segments.next().ok_or(AuthenticationError::Malformed)?; + let signature = segments.next().ok_or(AuthenticationError::Malformed)?; + if segments.next().is_some() || header.is_empty() || claims.is_empty() || signature.is_empty() { + return Err(AuthenticationError::Malformed); + } + let header = decode_segment(header)?; + let claims = decode_segment(claims)?; + let _signature = decode_segment(signature)?; + reject_duplicate_json_members(&header)?; + reject_duplicate_json_members(&claims)?; + Ok(()) +} + +fn decode_segment(segment: &str) -> Result, AuthenticationError> { + let decoded = URL_SAFE_NO_PAD + .decode(segment) + .map_err(|_| AuthenticationError::Malformed)?; + if decoded.is_empty() || decoded.len() > MAX_TOKEN_SEGMENT_BYTES { + return Err(AuthenticationError::Malformed); + } + Ok(decoded) +} + +fn reject_duplicate_json_members(bytes: &[u8]) -> Result<(), AuthenticationError> { + let mut deserializer = serde_json::Deserializer::from_slice(bytes); + StrictObject + .deserialize(&mut deserializer) + .map_err(|_| AuthenticationError::Malformed)?; + deserializer + .end() + .map_err(|_| AuthenticationError::Malformed) +} + +struct StrictObject; + +impl<'de> de::DeserializeSeed<'de> for StrictObject { + type Value = (); + + fn deserialize(self, deserializer: D) -> Result + where + D: de::Deserializer<'de>, + { + deserializer.deserialize_map(StrictValueVisitor) + } +} + +struct StrictValue; + +impl<'de> de::DeserializeSeed<'de> for StrictValue { + type Value = (); + + fn deserialize(self, deserializer: D) -> Result + where + D: de::Deserializer<'de>, + { + deserializer.deserialize_any(StrictValueVisitor) + } +} + +struct StrictValueVisitor; + +impl<'de> Visitor<'de> for StrictValueVisitor { + type Value = (); + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a JSON value without duplicate object members") + } + + fn visit_map(self, mut map: A) -> Result + where + A: de::MapAccess<'de>, + { + let mut names = BTreeSet::new(); + while let Some(name) = map.next_key::()? { + if !names.insert(name) { + return Err(de::Error::custom("duplicate JSON object member")); + } + map.next_value_seed(StrictValue)?; + } + Ok(()) + } + + fn visit_seq(self, mut sequence: A) -> Result + where + A: de::SeqAccess<'de>, + { + while sequence.next_element_seed(StrictValue)?.is_some() {} + Ok(()) + } + + fn visit_bool(self, _value: bool) -> Result { + Ok(()) + } + + fn visit_i64(self, _value: i64) -> Result { + Ok(()) + } + + fn visit_u64(self, _value: u64) -> Result { + Ok(()) + } + + fn visit_f64(self, _value: f64) -> Result { + Ok(()) + } + + fn visit_str(self, _value: &str) -> Result { + Ok(()) + } + + fn visit_string(self, _value: String) -> Result { + Ok(()) + } + + fn visit_none(self) -> Result { + Ok(()) + } + + fn visit_unit(self) -> Result { + Ok(()) + } +} + +fn direct_string<'a>(claims: Option<&'a Map>, name: &str) -> Option<&'a str> { + direct_string_value(claims?.get(name)?) +} + +fn direct_string_value(value: &Value) -> Option<&str> { + let value = value.as_str()?; + (!value.is_empty() && value.len() <= MAX_DIRECT_CLAIM_BYTES).then_some(value) +} + +#[cfg(test)] +mod tests { + use super::*; + use registry_platform_oidc::Claims; + + #[test] + fn malformed_jwt_shape_is_rejected_before_verification() { + assert_eq!(strict_token_shape(""), Err(AuthenticationError::Malformed)); + assert_eq!( + strict_token_shape("a.b"), + Err(AuthenticationError::Malformed) + ); + assert_eq!( + strict_token_shape("a.b.c.d"), + Err(AuthenticationError::Malformed) + ); + } + + #[test] + fn duplicate_json_members_are_rejected_recursively() { + let encode = |value: &[u8]| URL_SAFE_NO_PAD.encode(value); + let signature = encode(b"signature"); + for header in [ + br#"{"alg":"EdDSA","alg":"none","typ":"at+jwt","kid":"key"}"#.as_slice(), + br#"{"alg":"EdDSA","typ":"at+jwt","kid":"one","kid":"two"}"#.as_slice(), + ] { + let token = format!( + "{}.{}.{}", + encode(header), + encode(br#"{"iss":"issuer"}"#), + signature + ); + assert_eq!( + strict_token_shape(&token), + Err(AuthenticationError::Malformed) + ); + } + for claims in [ + br#"{"jti":"one","jti":"two"}"#.as_slice(), + br#"{"iss":"one","iss":"two"}"#.as_slice(), + br#"{"scope":"read","scope":"write"}"#.as_slice(), + br#"{"authority":{"region":"one","region":"two"}}"#.as_slice(), + ] { + let token = format!( + "{}.{}.{}", + encode(br#"{"alg":"EdDSA","typ":"at+jwt","kid":"key"}"#), + encode(claims), + signature + ); + assert_eq!( + strict_token_shape(&token), + Err(AuthenticationError::Malformed) + ); + } + } + + #[test] + fn direct_string_rejects_empty_or_structured_claims() { + let claims = serde_json::json!({"subject": "", "object": {"id": "x"}}); + let object = claims.as_object(); + assert!(direct_string(object, "subject").is_none()); + assert!(direct_string(object, "object").is_none()); + } + + #[test] + fn malformed_subject_cannot_fall_back_to_client_identifier() { + let claims = serde_json::json!({"sub": [], "client_id": "client-a"}); + assert_eq!( + strict_principal_identifier(claims.as_object().expect("object")), + Err(AuthenticationError::Claims) + ); + } + + #[test] + fn malformed_authorization_is_never_anonymous() { + let mut headers = HeaderMap::new(); + headers.insert(AUTHORIZATION, "Basic abc".parse().expect("header")); + assert_eq!(bearer_token(&headers), Err(AuthenticationError::Malformed)); + } + + #[test] + fn verified_token_must_contain_not_before() { + let claims: Claims = serde_json::from_value(serde_json::json!({ + "sub": "caller", + "iat": 1, + "jti": "token-1" + })) + .expect("claims parse"); + let verified = VerifiedToken { + claims, + matched_client: None, + scopes: Vec::new(), + }; + assert!(matches!( + Principal::from_verified(verified, "relay", Duration::ZERO), + Err(AuthenticationError::Claims) + )); + } +} diff --git a/crates/registry-relay-v2/src/compiler.rs b/crates/registry-relay-v2/src/compiler.rs new file mode 100644 index 000000000..7392edb1e --- /dev/null +++ b/crates/registry-relay-v2/src/compiler.rs @@ -0,0 +1,2728 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Closed compilation from authored contract plus observed schema to one model. + +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; +use std::path::{Component, Path}; + +use registry_platform_canonical_json::canonicalize_json; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use url::Url; + +use crate::contract::{ + AccessRule, AuthorityRowBinding, ClassificationPartial, DataType, Handling, RegistryContract, + ReviewStatus, SourceProfile, +}; +use crate::model::{ + CapabilityFamily, ColumnAccount, ColumnUse, CompileProfile, CompileReport, CompiledAccess, + CompiledCodelist, CompiledDisclosureProfile, CompiledFilter, CompiledGovernedFile, + CompiledMetadataVisibility, CompiledOperation, CompiledPagination, CompiledProperty, + CompiledPurpose, CompiledRecordContext, CompiledRegistry, CompiledResource, CompiledRowBinding, + CompiledSelector, CompiledSource, ConsultationPattern, Diagnostic, DiagnosticSeverity, + EffectiveClassification, ObservedSourceSchema, OperationKind, QueryPlan, RowAuthoritySource, + StarterColumn, StarterContract, +}; + +const API_VERSION: &str = "relay.registrystack.org/v2alpha1"; +const RESERVED_PARAMETERS: [&str; 3] = ["pageSize", "cursor", "fields"]; +const MAXIMUM_RESOURCES: usize = 128; +const MAXIMUM_PROPERTIES_PER_RESOURCE: usize = 128; +const MAXIMUM_DISCLOSURE_PROFILES_PER_RESOURCE: usize = 64; +const MAXIMUM_LIST_FILTERS: usize = 32; +const MAXIMUM_LIST_ORDER_KEYS: usize = 32; +const MAXIMUM_LIST_PAGE_SIZE: u32 = 1_000; +const MAXIMUM_LOOKUP_REQUEST_BODY_BYTES: u32 = 1024 * 1024; +const MAXIMUM_LOOKUP_SELECTORS: usize = 32; +const MAXIMUM_SELECTOR_BYTES: u32 = 4 * 1024; + +pub type GovernedFileSet = BTreeMap>; + +pub fn compile_yaml( + yaml: &str, + observed: &[ObservedSourceSchema], + profile: CompileProfile, +) -> Result { + let contract = RegistryContract::parse_yaml(yaml).map_err(|_| CompileReport { + diagnostics: vec![Diagnostic { + severity: DiagnosticSeverity::Error, + code: "contract.yaml_invalid".into(), + location: "registry.yaml".into(), + message: "the governed contract is not valid strict YAML".into(), + }], + })?; + compile_contract(&contract, observed, profile) +} + +pub fn compile_contract( + contract: &RegistryContract, + observed: &[ObservedSourceSchema], + profile: CompileProfile, +) -> Result { + let mut compiler = Compiler::new(contract, observed, profile); + compiler.validate_top_level(); + let resources = compiler.compile_resources(); + compiler.validate_observed_source_closure(); + + if compiler.report.has_errors() { + return Err(compiler.report); + } + + let contract_revision = revision(contract).map_err(|()| CompileReport { + diagnostics: vec![Diagnostic { + severity: DiagnosticSeverity::Error, + code: "contract.canonicalization_failed".into(), + location: "registry.yaml".into(), + message: "the governed contract could not be canonicalized".into(), + }], + })?; + + Ok(CompiledRegistry { + contract_revision, + contract_id: contract.metadata.id.clone(), + contract_version: contract.metadata.version.clone(), + registry_identifier: contract.registry.registry_identifier.clone(), + registry_name: contract.registry.name.clone(), + authority_identifier: contract.registry.authority.identifier.clone(), + operator_identifier: contract + .registry + .operator + .as_ref() + .map(|operator| operator.identifier.clone()), + authoritative_scope: contract.registry.authoritative_scope.clone(), + base_uri: contract.registry.base_uri.clone(), + identifier_lifecycle_policy_ref: contract.registry.identifier_lifecycle_policy_ref.clone(), + alignment_targets: contract.registry.alignment_targets.clone(), + controller_identifier: contract.governance.controller.clone(), + publisher_identifier: contract.governance.publisher.clone(), + audit_owner_identifier: contract.governance.audit_owner.clone(), + local_vocabulary: contract.semantics.local_vocabulary.clone(), + semantic_alignments: contract.semantics.alignments.clone(), + governed_files: Vec::new(), + codelists: Vec::new(), + sources: contract + .sources + .iter() + .map(|(id, source)| CompiledSource { + id: id.to_owned(), + profile: source.profile, + expected_schema_fingerprint: source.expected_schema_fingerprint.clone(), + observed_schema: observed.iter().find(|schema| schema.source == id).cloned(), + }) + .collect(), + resources, + metadata_visibility: CompiledMetadataVisibility { + service: contract.metadata_visibility.service, + resources: contract.metadata_visibility.resources, + semantics: contract.metadata_visibility.semantics, + classifications: contract.metadata_visibility.classifications, + processing: contract.metadata_visibility.processing, + }, + }) +} + +/// Compile the complete governed file closure. Production packaging and +/// startup use this entry point so a sidecar or codelist change necessarily +/// changes the active contract revision. +pub fn compile_contract_with_governed_files( + contract: &RegistryContract, + observed: &[ObservedSourceSchema], + profile: CompileProfile, + files: &GovernedFileSet, +) -> Result { + let mut registry = compile_contract(contract, observed, profile)?; + let (codelists, file_digests, report) = validate_governed_files(contract, files, profile); + if report.has_errors() { + return Err(report); + } + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct RevisionInput<'a> { + contract: &'a RegistryContract, + governed_files: &'a BTreeMap, + } + registry.contract_revision = revision(&RevisionInput { + contract, + governed_files: &file_digests, + }) + .map_err(|()| CompileReport { + diagnostics: vec![Diagnostic { + severity: DiagnosticSeverity::Error, + code: "contract.canonicalization_failed".into(), + location: "registry.yaml".into(), + message: "the governed closure could not be canonicalized".into(), + }], + })?; + registry.codelists = codelists; + registry.governed_files = file_digests + .into_iter() + .map(|(path, sha256)| CompiledGovernedFile { + roles: governed_file_roles(contract, &path), + path, + sha256, + }) + .collect(); + Ok(registry) +} + +fn governed_file_roles(contract: &RegistryContract, path: &str) -> Vec { + let mut roles = Vec::new(); + if contract.registry.identifier_lifecycle_policy_ref == path { + roles.push("identifier-lifecycle-policy".into()); + } + if contract.classifications.provenance_ref == path { + roles.push("classification-provenance".into()); + } + for alignment in &contract.semantics.alignments { + if alignment.profile_ref == path { + roles.push(format!("semantic-alignment:{}", alignment.id)); + } + } + for resource in &contract.resources { + if resource.record_context.lifecycle_state.codelist == path { + roles.push(format!("codelist:{}:lifecycle-state", resource.id)); + } + for (property, definition) in resource.properties.iter() { + if definition.codelist.as_deref() == Some(path) { + roles.push(format!("codelist:{}:{property}", resource.id)); + } + } + for processing in &resource.processing_descriptions { + if processing.legal_basis_ref == path { + roles.push(format!( + "processing:{}:{}:legal-basis", + resource.id, processing.id + )); + } + if processing.dpv_profile_ref == path { + roles.push(format!( + "processing:{}:{}:dpv-profile", + resource.id, processing.id + )); + } + } + } + roles.sort(); + roles.dedup(); + roles +} + +/// Compatibility entry point for binaries that already hold the strict typed +/// contract. All semantics remain in [`compile_contract`]. +pub fn compile( + contract: &RegistryContract, + observed: &[ObservedSourceSchema], + profile: CompileProfile, +) -> Result { + compile_contract(contract, observed, profile) +} + +pub type CompileError = CompileReport; + +/// Derive an explicitly unreviewed starter from a schema-only observation. +/// This output is an authoring aid and is intentionally not a valid production +/// contract until a publisher reviews semantics, bindings, and classification. +pub fn derive_starter(schema: &ObservedSourceSchema, view: &str) -> Option { + let observed_view = schema + .views + .iter() + .find(|candidate| candidate.name == view)?; + Some(StarterContract { + source: schema.source.clone(), + view: view.to_owned(), + expected_schema_fingerprint: schema.fingerprint.clone(), + columns: observed_view + .columns + .iter() + .map(|column| StarterColumn { + source_column: column.name.clone(), + suggested_property: to_camel_case(&column.name), + suggested_type: suggested_data_type(&column.declared_type), + classification_status: ReviewStatus::Suggested, + }) + .collect(), + }) +} + +struct Compiler<'a> { + contract: &'a RegistryContract, + observed: HashMap<&'a str, &'a ObservedSourceSchema>, + profile: CompileProfile, + report: CompileReport, + scopes: HashSet, + resource_ids: HashSet, + used_observed_sources: HashSet<&'a str>, +} + +impl<'a> Compiler<'a> { + fn new( + contract: &'a RegistryContract, + observed: &'a [ObservedSourceSchema], + profile: CompileProfile, + ) -> Self { + let mut by_name = HashMap::new(); + let mut report = CompileReport { + diagnostics: Vec::new(), + }; + for item in observed { + if by_name.insert(item.source.as_str(), item).is_some() { + report.diagnostics.push(Diagnostic { + severity: DiagnosticSeverity::Error, + code: "source.observation_duplicate".into(), + location: "observed-schema".into(), + message: "a source has more than one observed schema".into(), + }); + } + } + Self { + contract, + observed: by_name, + profile, + report, + scopes: HashSet::new(), + resource_ids: HashSet::new(), + used_observed_sources: HashSet::new(), + } + } + + fn validate_top_level(&mut self) { + if self.contract.api_version != API_VERSION { + self.error( + "contract.api_version_unsupported", + "apiVersion", + "the contract API version is unsupported", + ); + } + if self.contract.kind != "RegistryContract" { + self.error( + "contract.kind_invalid", + "kind", + "the governed document kind must be RegistryContract", + ); + } + require_nonempty( + &mut self.report, + &self.contract.metadata.id, + "contract.id_empty", + "metadata.id", + ); + if !valid_kebab_identifier(&self.contract.metadata.id) { + self.error( + "contract.id_invalid", + "metadata.id", + "the contract identifier must be URL-safe kebab case", + ); + } + for (value, code, location) in [ + ( + self.contract.metadata.version.as_str(), + "contract.version_empty", + "metadata.version", + ), + ( + self.contract.metadata.title.as_str(), + "contract.title_empty", + "metadata.title", + ), + ( + self.contract.registry.name.as_str(), + "registry.name_empty", + "registry.name", + ), + ( + self.contract.registry.authoritative_scope.as_str(), + "registry.scope_empty", + "registry.authoritativeScope", + ), + ( + self.contract.registry.authority.identifier.as_str(), + "registry.authority_identifier_empty", + "registry.authority.identifier", + ), + ( + self.contract.registry.authority.name.as_str(), + "registry.authority_name_empty", + "registry.authority.name", + ), + ] { + require_nonempty(&mut self.report, value, code, location); + } + if let Some(operator) = &self.contract.registry.operator { + require_nonempty( + &mut self.report, + &operator.identifier, + "registry.operator_identifier_empty", + "registry.operator.identifier", + ); + require_nonempty( + &mut self.report, + &operator.name, + "registry.operator_name_empty", + "registry.operator.name", + ); + } + require_nonempty( + &mut self.report, + &self.contract.registry.registry_identifier, + "registry.identifier_empty", + "registry.registryIdentifier", + ); + if !valid_global_identifier(&self.contract.registry.registry_identifier) { + self.error( + "registry.identifier_invalid", + "registry.registryIdentifier", + "the Registry identifier must be a globally scoped URI", + ); + } + if !valid_absolute_url(&self.contract.registry.base_uri) { + self.error( + "registry.base_uri_invalid", + "registry.baseUri", + "the Registry base URI must be an absolute HTTP or HTTPS URL", + ); + } + if !valid_absolute_url(&self.contract.semantics.local_vocabulary) { + self.error( + "semantics.local_vocabulary_invalid", + "semantics.localVocabulary", + "the local vocabulary must be an absolute HTTP or HTTPS URL", + ); + } + if !valid_relative_reference(&self.contract.registry.identifier_lifecycle_policy_ref) { + self.error( + "registry.identifier_lifecycle_ref_invalid", + "registry.identifierLifecyclePolicyRef", + "the identifier lifecycle policy must be a contained relative file reference", + ); + } + if self.contract.registry.alignment_targets.is_empty() { + self.error( + "registry.alignment_targets_empty", + "registry.alignmentTargets", + "at least one directional alignment target is required", + ); + } + let mut target_names = HashSet::new(); + for (index, target) in self.contract.registry.alignment_targets.iter().enumerate() { + let location = format!("registry.alignmentTargets[{index}]"); + if !target_names.insert(target.name.as_str()) { + self.error( + "registry.alignment_target_duplicate", + &location, + "alignment target names must be unique", + ); + } + if target.name.trim().is_empty() + || target.version.trim().is_empty() + || target.status != "directional" + { + self.error( + "registry.alignment_target_invalid", + &location, + "alignment targets require a name, version, and directional status", + ); + } + } + for (value, location) in [ + ( + &self.contract.governance.controller, + "governance.controller", + ), + (&self.contract.governance.publisher, "governance.publisher"), + ( + &self.contract.governance.audit_owner, + "governance.auditOwner", + ), + ] { + if value.trim().is_empty() { + self.error( + "governance.identifier_empty", + location, + "governance role identifiers must be non-empty", + ); + } + } + if !valid_relative_reference(&self.contract.classifications.provenance_ref) { + self.error( + "classification.provenance_ref_invalid", + "classifications.provenanceRef", + "classification provenance must be a contained relative file reference", + ); + } + for (scheme, location) in [ + ( + &self.contract.classifications.privacy, + "classifications.privacy", + ), + ( + &self.contract.classifications.institutional, + "classifications.institutional", + ), + ( + &self.contract.classifications.handling, + "classifications.handling", + ), + ] { + if scheme.scheme.trim().is_empty() || scheme.version.trim().is_empty() { + self.error( + "classification.scheme_invalid", + location, + "classification schemes require a non-empty identifier and version", + ); + } + } + let mut alignment_ids = HashSet::new(); + for (index, alignment) in self.contract.semantics.alignments.iter().enumerate() { + let location = format!("semantics.alignments[{index}]"); + if !alignment_ids.insert(alignment.id.as_str()) { + self.error( + "semantics.alignment_duplicate", + &location, + "semantic alignment identifiers must be unique", + ); + } + if alignment.id.trim().is_empty() + || alignment.version.trim().is_empty() + || !valid_relative_reference(&alignment.profile_ref) + || !valid_sha256(&alignment.digest) + || !alignment.relation_required + { + self.error( + "semantics.alignment_invalid", + &location, + "semantic alignments must be versioned, digest-pinned contained files with explicit relations", + ); + } + } + if self.contract.sources.is_empty() { + self.error( + "source.none", + "sources", + "at least one reviewed SQLite source is required", + ); + } + if self.contract.resources.is_empty() { + self.error( + "resource.none", + "resources", + "at least one resource is required", + ); + } + if self.contract.resources.len() > MAXIMUM_RESOURCES { + self.error( + "resource.bound_exceeded", + "resources", + "the governed resource count exceeds the product ceiling", + ); + } + for (source_id, source) in self.contract.sources.iter() { + let location = format!("sources.{source_id}"); + if !valid_kebab_identifier(source_id) { + self.error( + "source.id_invalid", + &location, + "source identifiers must be URL-safe kebab case", + ); + } + if source.kind != "sqlite" { + self.error( + "source.kind_unsupported", + &format!("{location}.kind"), + "Version one supports only SQLite sources", + ); + } + if !valid_sha256(&source.expected_schema_fingerprint) { + self.error( + "source.schema_fingerprint_invalid", + &format!("{location}.expectedSchemaFingerprint"), + "the expected schema fingerprint must be a SHA-256 digest", + ); + } + match self.observed.get(source_id) { + Some(schema) => { + self.used_observed_sources.insert(source_id); + validate_observed_schema(&mut self.report, schema, &location); + if schema.fingerprint != source.expected_schema_fingerprint { + self.error( + "source.schema_fingerprint_mismatch", + &location, + "the observed schema does not match the governed fingerprint", + ); + } + } + None if self.profile == CompileProfile::Production => self.error( + "source.schema_observation_missing", + &location, + "production compilation requires the observed source schema", + ), + None => self.warning( + "source.schema_observation_missing", + &location, + "source bindings cannot be fully checked without an observed schema", + ), + } + } + if self.contract.metadata_visibility.service != crate::contract::Visibility::Public { + self.error( + "metadata.service_not_public", + "metadataVisibility.service", + "Registry service identity is always public", + ); + } + } + + fn compile_resources(&mut self) -> Vec { + let mut compiled = Vec::with_capacity(self.contract.resources.len()); + for (index, resource) in self.contract.resources.iter().enumerate() { + let root = format!("resources[{index}]"); + if resource.properties.len() > MAXIMUM_PROPERTIES_PER_RESOURCE { + self.error( + "property.bound_exceeded", + &format!("{root}.properties"), + "the governed property count exceeds the per-resource product ceiling", + ); + } + if resource.disclosure_profiles.len() > MAXIMUM_DISCLOSURE_PROFILES_PER_RESOURCE { + self.error( + "disclosure.bound_exceeded", + &format!("{root}.disclosureProfiles"), + "the governed disclosure-profile count exceeds the per-resource product ceiling", + ); + } + if !self.resource_ids.insert(resource.id.clone()) { + self.error( + "resource.id_duplicate", + &format!("{root}.id"), + "resource identifiers must be unique", + ); + } + if !valid_kebab_identifier(&resource.id) { + self.error( + "resource.id_invalid", + &format!("{root}.id"), + "a resource identifier must be URL-safe kebab case", + ); + } + if resource.title.trim().is_empty() || resource.description.trim().is_empty() { + self.error( + "resource.documentation_empty", + &root, + "resources require a non-empty title and description", + ); + } + if !valid_sql_identifier(&resource.source.view) { + self.error( + "resource.view_invalid", + &format!("{root}.source.view"), + "reviewed SQLite view names must be simple identifiers", + ); + } + let Some(source) = self.contract.sources.get(&resource.source.source) else { + self.error( + "resource.source_unknown", + &format!("{root}.source.source"), + "the resource names no governed source", + ); + continue; + }; + let observed_view = + self.observed + .get(resource.source.source.as_str()) + .and_then(|schema| { + schema + .views + .iter() + .find(|view| view.name == resource.source.view) + }); + let observed_columns = observed_view.map(|view| { + view.columns + .iter() + .map(|column| column.name.as_str()) + .collect::>() + }); + if self.observed.contains_key(resource.source.source.as_str()) + && observed_columns.is_none() + { + self.error( + "resource.view_unknown", + &format!("{root}.source.view"), + "the reviewed view is absent from the observed source schema", + ); + } + + let defaults = + effective_classification(self.contract, &resource.classification_defaults, None); + if defaults.is_none() { + self.error( + "classification.defaults_incomplete", + &format!("{root}.classificationDefaults"), + "resource classification defaults must resolve every dimension", + ); + } + + let mut property_names = HashSet::new(); + let mut property_columns: HashMap<&str, (&str, EffectiveClassification)> = + HashMap::new(); + let mut properties = Vec::with_capacity(resource.properties.len()); + for (name, property) in resource.properties.iter() { + let location = format!("{root}.properties.{name}"); + if !valid_camel_identifier(name) { + self.error( + "property.name_invalid", + &location, + "property keys must be URL-safe camelCase", + ); + } + if property.label.trim().is_empty() || property.description.trim().is_empty() { + self.error( + "property.documentation_empty", + &location, + "published properties require a non-empty label and description", + ); + } + if !valid_sql_identifier(&property.source_column) { + self.error( + "property.column_invalid", + &format!("{location}.sourceColumn"), + "property columns must be simple SQLite identifiers", + ); + } + if !property_names.insert(name) { + self.error( + "property.name_duplicate", + &location, + "property keys must be unique", + ); + } + if property_columns.contains_key(property.source_column.as_str()) { + self.error( + "property.column_reused", + &format!("{location}.sourceColumn"), + "one source column cannot back more than one public property", + ); + } + if !column_exists(observed_columns.as_ref(), &property.source_column) { + self.error( + "property.column_unknown", + &format!("{location}.sourceColumn"), + "the property source column is absent from the reviewed view", + ); + } + validate_codelist( + &mut self.report, + property.data_type, + property.codelist.as_deref(), + &location, + ); + if let Some(codelist) = property.codelist.as_deref() { + if !valid_relative_reference(codelist) { + self.error( + "datatype.codelist_ref_invalid", + &format!("{location}.codelist"), + "codelists must be contained relative file references", + ); + } + } + if let Some(observed) = observed_view.and_then(|view| { + view.columns + .iter() + .find(|column| column.name == property.source_column) + }) { + if !compatible_declared_type(property.data_type, &observed.declared_type) { + self.error( + "property.declared_type_incompatible", + &format!("{location}.type"), + "the published datatype is incompatible with the reviewed SQLite declaration", + ); + } + } + let classification = effective_classification( + self.contract, + &resource.classification_defaults, + Some(&property.classification), + ); + let Some(classification) = classification else { + self.error( + "classification.property_incomplete", + &format!("{location}.classification"), + "the property classification is incomplete after defaults", + ); + continue; + }; + if classification.privacy.trim().is_empty() + || classification.institutional.trim().is_empty() + { + self.error( + "classification.property_empty", + &format!("{location}.classification"), + "effective privacy and institutional classifications must be non-empty", + ); + } + self.validate_review_status(&classification, &format!("{location}.classification")); + let semantic_iri = match expand_local_term( + &self.contract.semantics.local_vocabulary, + &property.semantic_term, + ) { + Some(term) => term, + None => { + self.error( + "semantics.term_invalid", + &format!("{location}.semanticTerm"), + "a semantic term must be local:Name or an absolute HTTP or HTTPS IRI", + ); + property.semantic_term.clone() + } + }; + property_columns.insert( + property.source_column.as_str(), + (name, classification.clone()), + ); + properties.push(CompiledProperty { + name: name.to_owned(), + label: property.label.clone(), + description: property.description.clone(), + source_column: property.source_column.clone(), + data_type: property.data_type, + codelist: property.codelist.clone(), + source_required: property.source_required, + semantic_iri, + classification, + }); + } + + let mut disclosures = Vec::with_capacity(resource.disclosure_profiles.len()); + let mut disclosure_names = HashSet::new(); + for (name, disclosure) in resource.disclosure_profiles.iter() { + let location = format!("{root}.disclosureProfiles.{name}"); + if !valid_kebab_identifier(name) { + self.error( + "disclosure.id_invalid", + &location, + "disclosure profile identifiers must be URL-safe kebab case", + ); + } + if !disclosure_names.insert(name) { + self.error( + "disclosure.id_duplicate", + &location, + "disclosure profile identifiers must be unique", + ); + } + let mut selected = HashSet::new(); + let mut maximum_handling = Handling::Public; + if disclosure.properties.is_empty() { + self.error( + "disclosure.properties_empty", + &location, + "a disclosure profile must contain at least one property", + ); + } + for property_name in &disclosure.properties { + if !selected.insert(property_name.as_str()) { + self.error( + "disclosure.property_duplicate", + &location, + "a disclosure profile cannot repeat a property", + ); + } + match properties.iter().find(|item| item.name == *property_name) { + Some(property) => { + maximum_handling = + maximum_handling.max(property.classification.handling); + } + None => self.error( + "disclosure.property_unknown", + &location, + "a disclosure profile names no published property", + ), + } + } + disclosures.push(CompiledDisclosureProfile { + id: name.to_owned(), + properties: disclosure.properties.clone(), + maximum_handling, + }); + } + + let core = [ + ( + resource + .record_context + .record_identifier + .source_column + .as_str(), + ColumnUse::RecordIdentifier, + ), + ( + resource + .record_context + .revision_identifier + .source_column + .as_str(), + ColumnUse::RevisionIdentifier, + ), + ( + resource + .record_context + .lifecycle_state + .source_column + .as_str(), + ColumnUse::LifecycleState, + ), + ( + resource.record_context.recorded_at.source_column.as_str(), + ColumnUse::RecordedAt, + ), + ]; + if !valid_relative_reference(&resource.record_context.lifecycle_state.codelist) { + self.error( + "record.lifecycle_codelist_ref_invalid", + &format!("{root}.recordContext.lifecycleState.codelist"), + "the lifecycle codelist must be a contained relative file reference", + ); + } + let mut core_names = HashSet::new(); + for (column, _) in &core { + if !valid_sql_identifier(column) { + self.error( + "record.column_invalid", + &format!("{root}.recordContext"), + "Registry Core columns must be simple SQLite identifiers", + ); + } + if !core_names.insert(*column) { + self.error( + "record.column_duplicate", + &format!("{root}.recordContext"), + "Registry Core fields must bind distinct source columns", + ); + } + if !column_exists(observed_columns.as_ref(), column) { + self.error( + "record.column_unknown", + &format!("{root}.recordContext"), + "a Registry Core source column is absent from the reviewed view", + ); + } + } + + let mut operations = Vec::new(); + if let Some(list) = &resource.operations.list { + if source.profile == SourceProfile::LiveReadOnly { + self.error( + "operation.list_live_forbidden", + &format!("{root}.operations.list"), + "Version one live sources cannot compile a list operation", + ); + } + let operation = self.compile_list( + resource, + &properties, + &disclosures, + observed_columns.as_ref(), + &root, + list, + ); + if let Some(operation) = operation { + operations.push(operation); + } + } + if let Some(read) = &resource.operations.read { + if let Some(operation) = self.compile_simple_operation( + resource, + &properties, + &disclosures, + observed_columns.as_ref(), + &root, + "read", + OperationKind::Read, + &read.access, + &read.disclosure_profile, + ) { + operations.push(operation); + } + } + let mut lookup_ids = HashSet::new(); + for (lookup_index, lookup) in resource.operations.lookups.iter().enumerate() { + let location = format!("{root}.operations.lookups[{lookup_index}]"); + if !lookup_ids.insert(lookup.id.as_str()) { + self.error( + "operation.lookup_id_duplicate", + &format!("{location}.id"), + "lookup identifiers must be unique within a resource", + ); + } + if !valid_kebab_identifier(&lookup.id) { + self.error( + "operation.lookup_id_invalid", + &format!("{location}.id"), + "lookup identifiers must be URL-safe kebab case", + ); + } + if lookup.request_body.maximum_bytes == 0 + || lookup.request_body.maximum_bytes > MAXIMUM_LOOKUP_REQUEST_BODY_BYTES + { + self.error( + "lookup.body_bound_invalid", + &format!("{location}.requestBody.maximumBytes"), + "lookup request bodies require a positive byte bound within the product ceiling", + ); + } + if lookup.request_body.selectors.is_empty() + || lookup.request_body.selectors.len() > MAXIMUM_LOOKUP_SELECTORS + { + self.error( + "lookup.selectors_empty", + &format!("{location}.requestBody.selectors"), + "an exact lookup requires a bounded non-empty selector set", + ); + } + let mut selectors = Vec::with_capacity(lookup.request_body.selectors.len()); + for (selector_name, selector) in lookup.request_body.selectors.iter() { + let selector_location = + format!("{location}.requestBody.selectors.{selector_name}"); + if !valid_camel_identifier(selector_name) { + self.error( + "lookup.selector_name_invalid", + &selector_location, + "selector keys must be URL-safe camelCase", + ); + } + if !column_exists(observed_columns.as_ref(), &selector.source_column) { + self.error( + "lookup.selector_column_unknown", + &format!("{selector_location}.sourceColumn"), + "a selector source column is absent from the reviewed view", + ); + } + if !valid_sql_identifier(&selector.source_column) { + self.error( + "lookup.selector_column_invalid", + &format!("{selector_location}.sourceColumn"), + "selector columns must be simple SQLite identifiers", + ); + } + validate_codelist( + &mut self.report, + selector.data_type, + selector.codelist.as_deref(), + &selector_location, + ); + let bounds_invalid = match selector.data_type { + DataType::String => { + selector.maximum_bytes.is_none() + || selector.maximum_bytes == Some(0) + || selector + .maximum_bytes + .is_some_and(|maximum| maximum > MAXIMUM_SELECTOR_BYTES) + || selector.minimum_bytes == Some(0) + || selector + .minimum_bytes + .zip(selector.maximum_bytes) + .is_some_and(|(minimum, maximum)| minimum > maximum) + } + _ => selector.minimum_bytes.is_some() || selector.maximum_bytes.is_some(), + }; + if bounds_invalid { + self.error( + "lookup.selector_bounds_invalid", + &selector_location, + "string selectors require a positive maximum and ordered byte bounds; other types forbid byte bounds", + ); + } + selectors.push(CompiledSelector { + name: selector_name.to_owned(), + source_column: selector.source_column.clone(), + data_type: selector.data_type, + minimum_bytes: selector.minimum_bytes, + maximum_bytes: selector.maximum_bytes, + codelist: selector.codelist.clone(), + }); + } + if let Some(mut operation) = self.compile_simple_operation( + resource, + &properties, + &disclosures, + observed_columns.as_ref(), + &location, + "lookup", + OperationKind::Lookup { + name: lookup.id.clone(), + }, + &lookup.access, + &lookup.disclosure_profile, + ) { + operation.identifier = format!("{}.lookup.{}", resource.id, lookup.id); + operation.query.selectors = selectors; + operation.query.maximum_request_body_bytes = + Some(lookup.request_body.maximum_bytes); + operations.push(operation); + } + } + if operations.is_empty() { + self.error( + "operation.none", + &format!("{root}.operations"), + "a resource must compile at least one operation", + ); + } + + self.validate_processing(resource, &operations, &root); + let column_accounting = self.compile_column_accounting( + resource, + &properties, + &operations, + &property_columns, + &core, + observed_columns.as_ref(), + &root, + ); + self.apply_operation_handling(&mut operations, &column_accounting, &root); + self.validate_metadata_closure(resource, &operations, &properties, &root); + let semantic_class = match expand_local_term( + &self.contract.semantics.local_vocabulary, + &resource.semantic_class, + ) { + Some(value) => value, + None => { + self.error( + "semantics.class_invalid", + &format!("{root}.semanticClass"), + "a semantic class must be local:Name or an absolute HTTP or HTTPS IRI", + ); + resource.semantic_class.clone() + } + }; + compiled.push(CompiledResource { + id: resource.id.clone(), + title: resource.title.clone(), + description: resource.description.clone(), + semantic_class, + source: resource.source.source.clone(), + view: resource.source.view.clone(), + record_context: CompiledRecordContext { + record_identifier_column: resource + .record_context + .record_identifier + .source_column + .clone(), + revision_identifier_column: resource + .record_context + .revision_identifier + .source_column + .clone(), + lifecycle_state_column: resource + .record_context + .lifecycle_state + .source_column + .clone(), + lifecycle_state_codelist: resource + .record_context + .lifecycle_state + .codelist + .clone(), + recorded_at_column: resource.record_context.recorded_at.source_column.clone(), + schema_reference: artifact_url( + &self.contract.registry.base_uri, + &format!("{}-full-schema", resource.id), + ), + semantic_model_reference: artifact_url( + &self.contract.registry.base_uri, + &format!("{}-full-vocabulary", resource.id), + ), + }, + properties, + disclosure_profiles: disclosures, + operations, + column_accounting, + processing_descriptions: resource.processing_descriptions.clone(), + }); + } + compiled + } + + #[allow(clippy::too_many_arguments)] + fn compile_simple_operation( + &mut self, + resource: &crate::contract::ResourceDefinition, + properties: &[CompiledProperty], + disclosures: &[CompiledDisclosureProfile], + observed_columns: Option<&BTreeSet<&str>>, + root: &str, + operation_location: &str, + kind: OperationKind, + access: &AccessRule, + disclosure_name: &str, + ) -> Option { + let location = if operation_location == "lookup" { + root.to_owned() + } else { + format!("{root}.operations.{operation_location}") + }; + let disclosure = disclosures.iter().find(|item| item.id == disclosure_name); + let Some(disclosure) = disclosure else { + self.error( + "operation.disclosure_unknown", + &format!("{location}.disclosureProfile"), + "the operation names no disclosure profile", + ); + return None; + }; + let access = self.compile_access(access, observed_columns, &location)?; + validate_disclosure_access(&mut self.report, disclosure, &access, &location); + let projected_columns = projected_columns(resource, properties, &disclosure.properties); + let identifier = match &kind { + OperationKind::Read => format!("{}.read", resource.id), + OperationKind::List => format!("{}.list", resource.id), + OperationKind::Lookup { name } => format!("{}.lookup.{name}", resource.id), + }; + let pattern = match &kind { + OperationKind::List => ConsultationPattern::List, + OperationKind::Read => ConsultationPattern::Retrieve, + OperationKind::Lookup { .. } => ConsultationPattern::Search, + }; + let artifact_stem = operation_artifact_stem(&resource.id, &kind); + Some(CompiledOperation { + identifier, + family: CapabilityFamily::Consultation, + pattern, + kind, + access, + disclosure_profile: disclosure.id.clone(), + selectable_properties: disclosure.properties.clone(), + query: QueryPlan { + source: resource.source.source.clone(), + view: resource.source.view.clone(), + projected_columns, + filters: Vec::new(), + selectors: Vec::new(), + order_by: Vec::new(), + allow_unfiltered: false, + pagination: None, + maximum_request_body_bytes: None, + }, + maximum_handling: disclosure.maximum_handling, + schema_reference: artifact_url( + &self.contract.registry.base_uri, + &format!("{artifact_stem}-schema"), + ), + semantic_model_reference: artifact_url( + &self.contract.registry.base_uri, + &format!("{artifact_stem}-vocabulary"), + ), + context_reference: artifact_url( + &self.contract.registry.base_uri, + &format!("{artifact_stem}-context"), + ), + }) + } + + #[allow(clippy::too_many_arguments)] + fn compile_list( + &mut self, + resource: &crate::contract::ResourceDefinition, + properties: &[CompiledProperty], + disclosures: &[CompiledDisclosureProfile], + observed_columns: Option<&BTreeSet<&str>>, + root: &str, + list: &crate::contract::ListOperation, + ) -> Option { + let mut operation = self.compile_simple_operation( + resource, + properties, + disclosures, + observed_columns, + root, + "list", + OperationKind::List, + &list.access, + &list.disclosure_profile, + )?; + let location = format!("{root}.operations.list"); + if list.filters.len() > MAXIMUM_LIST_FILTERS { + self.error( + "list.filter_bound_exceeded", + &format!("{location}.filters"), + "the governed filter count exceeds the product ceiling", + ); + } + if list.order_by.len() > MAXIMUM_LIST_ORDER_KEYS { + self.error( + "list.order_bound_exceeded", + &format!("{location}.orderBy"), + "the governed order-key count exceeds the product ceiling", + ); + } + if list.filters.is_empty() && !list.allow_unfiltered { + self.error( + "list.no_reachable_query", + &location, + "a list without filters must allow the empty filter set", + ); + } + if list.pagination.default_page_size == 0 + || list.pagination.maximum_page_size == 0 + || list.pagination.maximum_page_size > MAXIMUM_LIST_PAGE_SIZE + || list.pagination.default_page_size > list.pagination.maximum_page_size + { + self.error( + "list.pagination_invalid", + &format!("{location}.pagination"), + "page bounds must be positive and the default cannot exceed the maximum", + ); + } + let mut filter_names = HashSet::new(); + for (index, filter) in list.filters.iter().enumerate() { + let filter_location = format!("{location}.filters[{index}]"); + if !valid_camel_identifier(&filter.name) + || RESERVED_PARAMETERS.contains(&filter.name.as_str()) + { + self.error( + "list.filter_name_invalid", + &format!("{filter_location}.name"), + "filter names must be non-reserved camelCase parameters", + ); + } + if !filter_names.insert(filter.name.as_str()) { + self.error( + "list.filter_name_duplicate", + &format!("{filter_location}.name"), + "list filter names must be unique", + ); + } + match properties + .iter() + .find(|property| property.name == filter.property) + { + Some(property) => { + if property.data_type != filter.data_type { + self.error( + "list.filter_type_mismatch", + &filter_location, + "a filter type must match its published property", + ); + } + if property.classification.privacy != "non-personal" { + self.error( + "list.filter_personal_forbidden", + &filter_location, + "Version one collection filters must be classified non-personal", + ); + } + operation.query.filters.push(CompiledFilter { + parameter: filter.name.clone(), + property: filter.property.clone(), + source_column: property.source_column.clone(), + data_type: filter.data_type, + }); + } + None => self.error( + "list.filter_property_unknown", + &filter_location, + "a list filter must name a published property", + ), + } + } + let mut order = HashSet::new(); + for property_name in &list.order_by { + if !order.insert(property_name.as_str()) { + self.error( + "list.order_duplicate", + &format!("{location}.orderBy"), + "fixed order keys must be unique", + ); + } + match properties + .iter() + .find(|property| property.name == *property_name) + { + Some(property) => operation + .query + .order_by + .push(property.source_column.clone()), + None => self.error( + "list.order_property_unknown", + &format!("{location}.orderBy"), + "a fixed order key must name a published property", + ), + } + } + let record_identifier = &resource.record_context.record_identifier.source_column; + if !operation.query.order_by.contains(record_identifier) { + operation.query.order_by.push(record_identifier.clone()); + } + operation.query.allow_unfiltered = list.allow_unfiltered; + operation.query.pagination = Some(CompiledPagination { + default_page_size: list.pagination.default_page_size, + maximum_page_size: list.pagination.maximum_page_size, + }); + Some(operation) + } + + fn compile_access( + &mut self, + access: &AccessRule, + observed_columns: Option<&BTreeSet<&str>>, + location: &str, + ) -> Option { + match access { + AccessRule::Public(value) => { + if value != "public" { + self.error( + "access.public_invalid", + &format!("{location}.access"), + "anonymous access must be the exact public literal", + ); + } + Some(CompiledAccess::Public) + } + AccessRule::Protected(protected) => { + if protected.scope.trim().is_empty() { + self.error( + "access.scope_empty", + &format!("{location}.access.scope"), + "protected operations require one non-empty scope", + ); + } else if !self.scopes.insert(protected.scope.clone()) { + self.error( + "access.scope_duplicate", + &format!("{location}.access.scope"), + "operation scopes must be globally unique in one Registry", + ); + } + let purpose = protected.purpose.as_ref().map(|purpose| { + if purpose.claim.trim().is_empty() || purpose.allowed.is_empty() { + self.error( + "access.purpose_invalid", + &format!("{location}.access.purpose"), + "a purpose constraint requires one claim and allowed values", + ); + } + if has_duplicates(&purpose.allowed) { + self.error( + "access.purpose_duplicate", + &format!("{location}.access.purpose.allowed"), + "allowed purpose values must be unique", + ); + } + CompiledPurpose { + claim: purpose.claim.clone(), + allowed: purpose.allowed.clone(), + } + }); + let row_binding = protected.authority_row_binding.as_ref().map(|binding| { + let (source, column, valid) = match binding { + AuthorityRowBinding::Claim(binding) => ( + RowAuthoritySource::Claim(binding.claim.clone()), + binding.source_column.clone(), + !binding.claim.trim().is_empty(), + ), + AuthorityRowBinding::Principal(binding) => ( + RowAuthoritySource::Principal, + binding.source_column.clone(), + binding.principal, + ), + }; + if !valid { + self.error( + "access.row_binding_source_invalid", + &format!("{location}.access.authorityRowBinding"), + "a row binding must select one direct claim or the resolved principal", + ); + } + if !column_exists(observed_columns, &column) { + self.error( + "access.row_binding_column_unknown", + &format!("{location}.access.authorityRowBinding.sourceColumn"), + "the row-binding column is absent from the reviewed view", + ); + } + CompiledRowBinding { + source, + source_column: column, + } + }); + Some(CompiledAccess::Protected { + scope: protected.scope.clone(), + purpose, + row_binding, + }) + } + } + } + + #[allow(clippy::too_many_arguments)] + fn compile_column_accounting( + &mut self, + resource: &crate::contract::ResourceDefinition, + properties: &[CompiledProperty], + operations: &[CompiledOperation], + property_columns: &HashMap<&str, (&str, EffectiveClassification)>, + core: &[(&str, ColumnUse); 4], + observed_columns: Option<&BTreeSet<&str>>, + root: &str, + ) -> Vec { + let mut uses: BTreeMap<&str, BTreeSet> = BTreeMap::new(); + for (column, usage) in core { + uses.entry(column).or_default().insert(usage.clone()); + } + for property in properties { + uses.entry(&property.source_column) + .or_default() + .insert(ColumnUse::Property(property.name.clone())); + } + for operation in operations { + for filter in &operation.query.filters { + uses.entry(&filter.source_column) + .or_default() + .insert(ColumnUse::Filter(filter.parameter.clone())); + } + for column in &operation.query.order_by { + uses.entry(column).or_default().insert(ColumnUse::Order); + } + for selector in &operation.query.selectors { + uses.entry(&selector.source_column) + .or_default() + .insert(ColumnUse::Selector(selector.name.clone())); + } + if let CompiledAccess::Protected { + row_binding: Some(row_binding), + .. + } = &operation.access + { + uses.entry(&row_binding.source_column) + .or_default() + .insert(ColumnUse::RowBinding(operation.identifier.clone())); + } + } + if let Some(columns) = observed_columns { + for column in columns { + if !uses.contains_key(column) { + self.error( + "source.column_unaccounted", + &format!("{root}.source"), + "the reviewed view contains an unaccounted column", + ); + } + } + } + for (column, _) in resource.source_column_classifications.iter() { + if !uses.contains_key(column) { + self.error( + "classification.column_override_unknown", + &format!("{root}.sourceColumnClassifications.{column}"), + "a source-column classification override must name an accounted reviewed column", + ); + } + } + + let mut accounts = Vec::with_capacity(uses.len()); + for (column, column_uses) in uses { + let source_override = resource.source_column_classifications.get(column); + let property_classification = property_columns.get(column).map(|(_, item)| item); + let classification = match property_classification { + Some(property) => effective_classification( + self.contract, + &classification_to_partial(property), + source_override, + ), + None => effective_classification( + self.contract, + &resource.classification_defaults, + source_override, + ), + }; + let Some(classification) = classification else { + self.error( + "classification.column_incomplete", + &format!("{root}.sourceColumnClassifications"), + "an accounted source column has no complete classification", + ); + continue; + }; + if let Some(property) = property_classification { + if classification.handling < property.handling { + self.error( + "classification.column_weaker_than_property", + &format!("{root}.sourceColumnClassifications"), + "a source-column override cannot weaken property handling", + ); + } + } + self.validate_review_status( + &classification, + &format!("{root}.sourceColumnClassifications"), + ); + accounts.push(ColumnAccount { + column: column.to_owned(), + uses: column_uses.into_iter().collect(), + classification, + }); + } + accounts + } + + fn apply_operation_handling( + &mut self, + operations: &mut [CompiledOperation], + columns: &[ColumnAccount], + root: &str, + ) { + for operation in operations { + let mut referenced = BTreeSet::new(); + referenced.extend(operation.query.projected_columns.iter().map(String::as_str)); + referenced.extend( + operation + .query + .filters + .iter() + .map(|filter| filter.source_column.as_str()), + ); + referenced.extend(operation.query.order_by.iter().map(String::as_str)); + referenced.extend( + operation + .query + .selectors + .iter() + .map(|selector| selector.source_column.as_str()), + ); + if let CompiledAccess::Protected { + row_binding: Some(binding), + .. + } = &operation.access + { + referenced.insert(&binding.source_column); + } + operation.maximum_handling = columns + .iter() + .filter(|column| referenced.contains(column.column.as_str())) + .fold(operation.maximum_handling, |maximum, column| { + maximum.max(column.classification.handling) + }); + let location = match &operation.kind { + OperationKind::List => format!("{root}.operations.list"), + OperationKind::Read => format!("{root}.operations.read"), + OperationKind::Lookup { name } => { + format!("{root}.operations.lookups.{name}") + } + }; + if operation.maximum_handling > Handling::Public + && matches!(operation.access, CompiledAccess::Public) + { + self.error( + "access.public_nonpublic_forbidden", + &location, + "anonymous operations may process only public-handling reviewed columns", + ); + } + if operation.maximum_handling == Handling::Restricted + && matches!(&operation.kind, OperationKind::List) + { + self.error( + "operation.restricted_list_forbidden", + &location, + "restricted reviewed data cannot be processed by a collection list", + ); + } + } + } + + fn validate_metadata_closure( + &mut self, + _resource: &crate::contract::ResourceDefinition, + operations: &[CompiledOperation], + properties: &[CompiledProperty], + root: &str, + ) { + use crate::contract::Visibility; + + let has_public = operations + .iter() + .any(|operation| matches!(operation.access, CompiledAccess::Public)); + for (name, visibility) in [ + ("resources", self.contract.metadata_visibility.resources), + ("semantics", self.contract.metadata_visibility.semantics), + ] { + if visibility == Visibility::OperatorOnly + || (has_public && visibility != Visibility::Public) + { + self.error( + "metadata.reference_visibility_invalid", + &format!("metadataVisibility.{name}"), + "every Record audience must be able to resolve its resource and semantic references", + ); + } + } + let confidential = properties + .iter() + .any(|property| property.classification.handling >= Handling::Confidential); + if confidential && self.contract.metadata_visibility.classifications == Visibility::Public { + self.error( + "metadata.classification_visibility_invalid", + &format!("{root}.properties"), + "confidential or restricted properties forbid public classification metadata", + ); + } + if confidential && self.contract.metadata_visibility.processing == Visibility::Public { + self.error( + "metadata.processing_visibility_invalid", + &format!("{root}.processingDescriptions"), + "confidential or restricted properties forbid public processing metadata", + ); + } + } + + fn validate_processing( + &mut self, + resource: &crate::contract::ResourceDefinition, + operations: &[CompiledOperation], + root: &str, + ) { + let mut ids = HashSet::new(); + for (index, processing) in resource.processing_descriptions.iter().enumerate() { + let location = format!("{root}.processingDescriptions[{index}]"); + if !ids.insert(processing.id.as_str()) { + self.error( + "processing.id_duplicate", + &location, + "processing description identifiers must be unique", + ); + } + if !valid_kebab_identifier(&processing.id) + || processing.purpose.trim().is_empty() + || processing.recipient_class.trim().is_empty() + || processing.safeguards.is_empty() + || has_duplicates(&processing.safeguards) + || !valid_relative_reference(&processing.legal_basis_ref) + || !valid_relative_reference(&processing.dpv_profile_ref) + { + self.error( + "processing.description_invalid", + &location, + "processing descriptions require stable identifiers, contained governance references, and reviewed safeguards", + ); + } + if processing.operation_refs.is_empty() || has_duplicates(&processing.operation_refs) { + self.error( + "processing.operations_invalid", + &format!("{location}.operationRefs"), + "processing descriptions require a duplicate-free operation set", + ); + } + for reference in &processing.operation_refs { + let present = operations.iter().any(|operation| match &operation.kind { + OperationKind::List => reference == "list", + OperationKind::Read => reference == "read", + OperationKind::Lookup { name } => reference == &format!("lookup:{name}"), + }); + if !present { + self.error( + "processing.operation_unknown", + &format!("{location}.operationRefs"), + "a processing sidecar names no compiled operation", + ); + } + } + } + } + + fn validate_review_status(&mut self, classification: &EffectiveClassification, location: &str) { + if classification.status != ReviewStatus::Reviewed { + match self.profile { + CompileProfile::Authoring => self.warning( + "classification.unreviewed", + location, + "classification suggestions require institutional review", + ), + CompileProfile::Production => self.error( + "classification.unreviewed", + location, + "production compilation requires reviewed classification", + ), + } + } + } + + fn validate_observed_source_closure(&mut self) { + for source in self.observed.keys().copied().collect::>() { + if self.contract.sources.get(source).is_none() { + self.error( + "source.observation_unknown", + "observed-schema", + "an observed schema does not belong to a governed source", + ); + } + } + } + + fn error(&mut self, code: &str, location: &str, message: &str) { + self.report.diagnostics.push(Diagnostic { + severity: DiagnosticSeverity::Error, + code: code.into(), + location: location.into(), + message: message.into(), + }); + } + + fn warning(&mut self, code: &str, location: &str, message: &str) { + self.report.diagnostics.push(Diagnostic { + severity: DiagnosticSeverity::Warning, + code: code.into(), + location: location.into(), + message: message.into(), + }); + } +} + +fn revision(value: &T) -> Result { + let json = serde_json::to_value(value).map_err(|_| ())?; + let canonical = canonicalize_json(&json).map_err(|_| ())?; + Ok(format!("sha256:{}", hex::encode(Sha256::digest(canonical)))) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct CodelistDocument { + id: String, + version: serde_json::Value, + values: Vec, + #[serde(default)] + status: Option, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct SemanticAlignmentDocument { + schema_version: String, + profile: String, + profile_version: String, + #[serde(default)] + profile_digest: Option, + status: String, + mappings: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct SemanticMapping { + local: String, + external: String, + relation: String, +} + +fn validate_governed_files( + contract: &RegistryContract, + files: &GovernedFileSet, + profile: CompileProfile, +) -> ( + Vec, + BTreeMap, + CompileReport, +) { + let mut report = CompileReport { + diagnostics: Vec::new(), + }; + let total_bytes = files + .values() + .try_fold(0_usize, |total, content| total.checked_add(content.len())); + if files.len() > 256 || total_bytes.is_none_or(|total| total > 16 * 1024 * 1024) { + report.diagnostics.push(Diagnostic { + severity: DiagnosticSeverity::Error, + code: "contract.governed_closure_bound".into(), + location: "governed".into(), + message: "the governed file closure exceeds its file or byte bound".into(), + }); + return (Vec::new(), BTreeMap::new(), report); + } + let mut codelist_paths = BTreeSet::new(); + let mut sidecar_paths = BTreeSet::new(); + codelist_paths.insert(contract.registry.identifier_lifecycle_policy_ref.as_str()); + sidecar_paths.insert(contract.registry.identifier_lifecycle_policy_ref.as_str()); + sidecar_paths.insert(contract.classifications.provenance_ref.as_str()); + for alignment in &contract.semantics.alignments { + sidecar_paths.insert(alignment.profile_ref.as_str()); + } + for resource in &contract.resources { + codelist_paths.insert(resource.record_context.lifecycle_state.codelist.as_str()); + for (_, property) in resource.properties.iter() { + if let Some(codelist) = property.codelist.as_deref() { + codelist_paths.insert(codelist); + } + } + for processing in &resource.processing_descriptions { + sidecar_paths.insert(processing.legal_basis_ref.as_str()); + sidecar_paths.insert(processing.dpv_profile_ref.as_str()); + } + } + // The lifecycle policy is a governance sidecar, not a codelist. + codelist_paths.remove(contract.registry.identifier_lifecycle_policy_ref.as_str()); + let expected = sidecar_paths + .iter() + .chain(codelist_paths.iter()) + .copied() + .collect::>(); + for path in files.keys() { + if !expected.contains(path.as_str()) { + report.diagnostics.push(Diagnostic { + severity: DiagnosticSeverity::Error, + code: "contract.governed_file_unknown".into(), + location: path.clone(), + message: "the governed closure contains an unreferenced file".into(), + }); + } + } + let mut file_digests = BTreeMap::new(); + for path in &expected { + let Some(content) = files.get(*path) else { + report.diagnostics.push(Diagnostic { + severity: DiagnosticSeverity::Error, + code: "contract.governed_file_missing".into(), + location: (*path).into(), + message: "a referenced governed file is absent from the captured closure".into(), + }); + continue; + }; + file_digests.insert((*path).into(), digest(content)); + if !codelist_paths.contains(path) + && !contract + .semantics + .alignments + .iter() + .any(|alignment| alignment.profile_ref == **path) + && serde_norway::from_slice::(content).is_err() + { + report.diagnostics.push(Diagnostic { + severity: DiagnosticSeverity::Error, + code: "contract.governance_yaml_invalid".into(), + location: (*path).into(), + message: "a governance sidecar is not valid YAML".into(), + }); + } + } + let mut codelists = Vec::new(); + for path in codelist_paths { + let Some(content) = files.get(path) else { + continue; + }; + let document = match serde_norway::from_slice::(content) { + Ok(document) => document, + Err(_) => { + report.diagnostics.push(Diagnostic { + severity: DiagnosticSeverity::Error, + code: "codelist.yaml_invalid".into(), + location: path.into(), + message: "a codelist is not valid strict YAML".into(), + }); + continue; + } + }; + let version = scalar_text(&document.version); + let mut values = HashSet::new(); + if document.id.trim().is_empty() + || version.is_none() + || document.values.is_empty() + || document + .values + .iter() + .any(|value| value.trim().is_empty() || !values.insert(value.as_str())) + { + report.diagnostics.push(Diagnostic { + severity: DiagnosticSeverity::Error, + code: "codelist.content_invalid".into(), + location: path.into(), + message: + "codelists require an identifier, scalar version, and unique non-empty values" + .into(), + }); + continue; + } + if profile == CompileProfile::Production && document.status != Some(ReviewStatus::Reviewed) + { + report.diagnostics.push(Diagnostic { + severity: DiagnosticSeverity::Error, + code: "codelist.unreviewed".into(), + location: path.into(), + message: "production codelists must be institutionally reviewed".into(), + }); + continue; + } + codelists.push(CompiledCodelist { + path: path.into(), + id: document.id, + version: version.expect("validated scalar version"), + values: document.values, + }); + } + for alignment in &contract.semantics.alignments { + let Some(content) = files.get(&alignment.profile_ref) else { + continue; + }; + if digest(content) != alignment.digest { + report.diagnostics.push(Diagnostic { + severity: DiagnosticSeverity::Error, + code: "semantics.alignment_digest_mismatch".into(), + location: alignment.profile_ref.clone(), + message: "the semantic alignment file does not match its governed digest".into(), + }); + continue; + } + let document = match serde_norway::from_slice::(content) { + Ok(document) => document, + Err(_) => { + report.diagnostics.push(Diagnostic { + severity: DiagnosticSeverity::Error, + code: "semantics.alignment_yaml_invalid".into(), + location: alignment.profile_ref.clone(), + message: "the semantic alignment is not valid strict YAML".into(), + }); + continue; + } + }; + let valid_document = document + .schema_version + .starts_with("relay.registrystack.org/semantic-alignment/") + && valid_absolute_url(&document.profile) + && !document.profile_version.trim().is_empty() + && document.profile_digest.as_deref().is_none_or(valid_sha256) + && !document.status.trim().is_empty() + && !document.mappings.is_empty() + && document.mappings.iter().all(|mapping| { + expand_local_term(&contract.semantics.local_vocabulary, &mapping.local).is_some() + && valid_absolute_url(&mapping.external) + && matches!( + mapping.relation.as_str(), + "exact" | "close" | "broad" | "narrow" | "related" + ) + }); + if !valid_document { + report.diagnostics.push(Diagnostic { + severity: DiagnosticSeverity::Error, + code: "semantics.alignment_content_invalid".into(), + location: alignment.profile_ref.clone(), + message: "the semantic alignment is incomplete or contains an unsupported relation" + .into(), + }); + } + } + codelists.sort_by(|left, right| left.path.cmp(&right.path)); + (codelists, file_digests, report) +} + +fn digest(content: &[u8]) -> String { + format!("sha256:{}", hex::encode(Sha256::digest(content))) +} + +fn scalar_text(value: &serde_json::Value) -> Option { + match value { + serde_json::Value::String(value) => Some(value.clone()), + serde_json::Value::Number(value) => Some(value.to_string()), + _ => None, + } +} + +fn effective_classification( + contract: &RegistryContract, + defaults: &ClassificationPartial, + explicit: Option<&ClassificationPartial>, +) -> Option { + let explicit = explicit.cloned().unwrap_or_default(); + Some(EffectiveClassification { + privacy: explicit.privacy.or_else(|| defaults.privacy.clone())?, + privacy_scheme: contract.classifications.privacy.scheme.clone(), + privacy_version: contract.classifications.privacy.version.clone(), + institutional: explicit + .institutional + .or_else(|| defaults.institutional.clone())?, + institutional_scheme: contract.classifications.institutional.scheme.clone(), + institutional_version: contract.classifications.institutional.version.clone(), + handling: explicit.handling.or(defaults.handling)?, + handling_scheme: contract.classifications.handling.scheme.clone(), + handling_version: contract.classifications.handling.version.clone(), + status: explicit.status.or(defaults.status)?, + provenance_ref: contract.classifications.provenance_ref.clone(), + }) +} + +fn classification_to_partial(value: &EffectiveClassification) -> ClassificationPartial { + ClassificationPartial { + privacy: Some(value.privacy.clone()), + institutional: Some(value.institutional.clone()), + handling: Some(value.handling), + status: Some(value.status), + } +} + +fn validate_disclosure_access( + report: &mut CompileReport, + disclosure: &CompiledDisclosureProfile, + access: &CompiledAccess, + location: &str, +) { + if disclosure.maximum_handling > Handling::Public && matches!(access, CompiledAccess::Public) { + report.diagnostics.push(Diagnostic { + severity: DiagnosticSeverity::Error, + code: "disclosure.public_nonpublic_forbidden".into(), + location: location.into(), + message: "public operations may disclose only public handling data".into(), + }); + } + if disclosure.maximum_handling == Handling::Restricted && location.ends_with("operations.list") + { + report.diagnostics.push(Diagnostic { + severity: DiagnosticSeverity::Error, + code: "disclosure.restricted_list_forbidden".into(), + location: location.into(), + message: "restricted properties cannot be disclosed by a list operation".into(), + }); + } +} + +fn projected_columns( + resource: &crate::contract::ResourceDefinition, + properties: &[CompiledProperty], + disclosure: &[String], +) -> Vec { + let mut columns = Vec::new(); + for column in [ + &resource.record_context.record_identifier.source_column, + &resource.record_context.revision_identifier.source_column, + &resource.record_context.lifecycle_state.source_column, + &resource.record_context.recorded_at.source_column, + ] { + push_unique(&mut columns, column); + } + // Full reviewed property projection is intentional: authoritative source + // validation precedes narrowing and serialization. + for property in properties { + push_unique(&mut columns, &property.source_column); + } + // Preserve the disclosure reference in the calculation so a future + // derived property cannot accidentally be omitted from the full plan. + for name in disclosure { + if let Some(property) = properties.iter().find(|property| property.name == *name) { + push_unique(&mut columns, &property.source_column); + } + } + columns +} + +fn push_unique(values: &mut Vec, value: &str) { + if !values.iter().any(|candidate| candidate == value) { + values.push(value.to_owned()); + } +} + +fn validate_codelist( + report: &mut CompileReport, + data_type: DataType, + codelist: Option<&str>, + location: &str, +) { + let valid = match data_type { + DataType::ControlledCode => codelist.is_some_and(|value| !value.trim().is_empty()), + _ => codelist.is_none(), + }; + if !valid { + report.diagnostics.push(Diagnostic { + severity: DiagnosticSeverity::Error, + code: "datatype.codelist_invalid".into(), + location: location.into(), + message: "controlled-code requires one codelist and other types forbid it".into(), + }); + } +} + +fn require_nonempty(report: &mut CompileReport, value: &str, code: &str, location: &str) { + if value.trim().is_empty() { + report.diagnostics.push(Diagnostic { + severity: DiagnosticSeverity::Error, + code: code.into(), + location: location.into(), + message: "a required governed identifier is empty".into(), + }); + } +} + +fn column_exists(columns: Option<&BTreeSet<&str>>, column: &str) -> bool { + columns.is_none_or(|columns| columns.contains(column)) +} + +fn valid_sha256(value: &str) -> bool { + value.strip_prefix("sha256:").is_some_and(|digest| { + digest.len() == 64 && digest.bytes().all(|byte| byte.is_ascii_hexdigit()) + }) +} + +fn valid_absolute_url(value: &str) -> bool { + Url::parse(value) + .ok() + .is_some_and(|url| matches!(url.scheme(), "http" | "https") && url.has_host()) +} + +fn valid_global_identifier(value: &str) -> bool { + if let Some(rest) = value.strip_prefix("urn:") { + return !rest.is_empty() && !rest.chars().any(char::is_whitespace); + } + valid_absolute_url(value) +} + +fn valid_relative_reference(value: &str) -> bool { + let path = Path::new(value); + !path.as_os_str().is_empty() + && !path.is_absolute() + && path + .components() + .all(|component| matches!(component, Component::Normal(_))) +} + +fn valid_sql_identifier(value: &str) -> bool { + let mut bytes = value.bytes(); + matches!(bytes.next(), Some(first) if first.is_ascii_alphabetic() || first == b'_') + && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') +} + +fn compatible_declared_type(data_type: DataType, declared_type: &str) -> bool { + let declared = declared_type.trim().to_ascii_uppercase(); + match data_type { + DataType::Boolean | DataType::Integer => declared.contains("INT") || declared == "BOOLEAN", + DataType::String | DataType::Date | DataType::DateTime | DataType::ControlledCode => { + declared.contains("CHAR") + || declared.contains("CLOB") + || declared.contains("TEXT") + || declared == "DATE" + || declared == "DATETIME" + } + } +} + +fn validate_observed_schema( + report: &mut CompileReport, + schema: &ObservedSourceSchema, + location: &str, +) { + if !valid_sha256(&schema.fingerprint) { + report.diagnostics.push(Diagnostic { + severity: DiagnosticSeverity::Error, + code: "source.observed_fingerprint_invalid".into(), + location: location.into(), + message: "the observed schema fingerprint is not a SHA-256 digest".into(), + }); + } + let mut views = HashSet::new(); + for view in &schema.views { + if !views.insert(view.name.as_str()) || !valid_sql_identifier(&view.name) { + report.diagnostics.push(Diagnostic { + severity: DiagnosticSeverity::Error, + code: "source.observed_view_invalid".into(), + location: location.into(), + message: "observed view identifiers must be unique simple SQLite identifiers" + .into(), + }); + } + let mut columns = HashSet::new(); + for column in &view.columns { + if !columns.insert(column.name.as_str()) || !valid_sql_identifier(&column.name) { + report.diagnostics.push(Diagnostic { + severity: DiagnosticSeverity::Error, + code: "source.observed_column_invalid".into(), + location: location.into(), + message: "observed column identifiers must be unique simple SQLite identifiers" + .into(), + }); + } + } + } +} + +fn expand_local_term(base: &str, term: &str) -> Option { + if let Some(local) = term.strip_prefix("local:") { + if local.is_empty() || local.contains(|character: char| character.is_whitespace()) { + return None; + } + return Some(format!("{base}{local}")); + } + valid_absolute_url(term).then(|| term.to_owned()) +} + +fn artifact_url(base: &str, artifact_id: &str) -> String { + Url::parse(base).map_or_else( + |_| format!("{base}v2/artifacts/{artifact_id}"), + |mut url| { + url.set_path(&format!("/v2/artifacts/{artifact_id}")); + url.set_query(None); + url.set_fragment(None); + url.to_string() + }, + ) +} + +fn operation_artifact_stem(resource: &str, kind: &OperationKind) -> String { + match kind { + OperationKind::List => format!("{resource}--list"), + OperationKind::Read => format!("{resource}--read"), + OperationKind::Lookup { name } => format!("{resource}--lookup-{name}"), + } +} + +fn valid_camel_identifier(value: &str) -> bool { + let mut bytes = value.bytes(); + matches!(bytes.next(), Some(first) if first.is_ascii_lowercase()) + && bytes.all(|byte| byte.is_ascii_alphanumeric()) +} + +fn valid_kebab_identifier(value: &str) -> bool { + !value.is_empty() + && !value.starts_with('-') + && !value.ends_with('-') + && value + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + && !value.contains("--") +} + +fn has_duplicates(values: &[String]) -> bool { + let mut seen = HashSet::new(); + values.iter().any(|value| !seen.insert(value)) +} + +fn suggested_data_type(declared_type: &str) -> DataType { + let normalized = declared_type.to_ascii_uppercase(); + if normalized.contains("BOOL") { + DataType::Boolean + } else if normalized.contains("INT") { + DataType::Integer + } else { + DataType::String + } +} + +fn to_camel_case(value: &str) -> String { + let mut output = String::new(); + let mut upper = false; + for character in value.chars() { + if character.is_ascii_alphanumeric() { + if output.is_empty() { + output.push(character.to_ascii_lowercase()); + } else if upper { + output.push(character.to_ascii_uppercase()); + upper = false; + } else { + output.push(character); + } + } else { + upper = !output.is_empty(); + } + } + if output.is_empty() { + "column".into() + } else { + output + } +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + + #[test] + fn starter_never_marks_classification_reviewed() { + let schema = ObservedSourceSchema { + source: "source".into(), + fingerprint: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .into(), + views: vec![crate::model::ObservedView { + name: "registry_records".into(), + columns: vec![crate::model::ObservedColumn { + name: "record_id".into(), + declared_type: "TEXT".into(), + nullable: false, + primary_key: false, + }], + }], + }; + let starter = derive_starter(&schema, "registry_records").expect("view exists"); + assert_eq!(starter.columns[0].suggested_property, "recordId"); + assert_eq!( + starter.columns[0].classification_status, + ReviewStatus::Suggested + ); + } + + #[test] + fn field_and_resource_names_have_closed_syntax() { + assert!(valid_camel_identifier("registrationStatus")); + assert!(!valid_camel_identifier("registration_status")); + assert!(!valid_camel_identifier("source.column")); + assert!(valid_kebab_identifier("registered-business")); + assert!(!valid_kebab_identifier("RegisteredBusiness")); + } + + #[test] + fn complete_governed_closure_compiles_reproducibly() { + let contract = RegistryContract::parse_yaml(valid_contract()).expect("strict contract"); + let first = compile_contract_with_governed_files( + &contract, + &[observed_schema()], + CompileProfile::Production, + &governed_files(), + ) + .expect("production compilation"); + let second = compile_contract_with_governed_files( + &contract, + &[observed_schema()], + CompileProfile::Production, + &governed_files(), + ) + .expect("repeat production compilation"); + + assert_eq!(first, second); + assert_eq!(first.codelists[0].values, ["ACTIVE", "RETIRED"]); + assert!(first.contract_revision.starts_with("sha256:")); + assert!(first.sources[0].observed_schema.is_some()); + let first_artifacts = crate::artifacts::generate_artifacts(&first).expect("artifacts"); + let second_artifacts = crate::artifacts::generate_artifacts(&second).expect("artifacts"); + assert_eq!(first_artifacts, second_artifacts); + let operation = &first.resources[0].operations[0]; + let schema = first_artifacts + .artifacts + .iter() + .find(|artifact| operation.schema_reference.ends_with(&artifact.id)) + .expect("operation schema is mounted by its exact artifact identifier"); + assert_eq!(schema.visibility, crate::contract::Visibility::Public); + } + + #[test] + fn production_requires_an_explicitly_reviewed_codelist() { + let contract = RegistryContract::parse_yaml(valid_contract()).expect("strict contract"); + let mut governed = governed_files(); + governed.insert( + "codelists/record-lifecycle.yaml".into(), + b"id: record-lifecycle\nversion: 1\nvalues: [ACTIVE, RETIRED]\n".to_vec(), + ); + let report = compile_contract_with_governed_files( + &contract, + &[observed_schema()], + CompileProfile::Production, + &governed, + ) + .expect_err("a codelist without review status is refused"); + assert!(report + .diagnostics + .iter() + .any(|item| item.code == "codelist.unreviewed")); + } + + #[test] + fn governed_query_bounds_cannot_exceed_product_ceilings() { + let oversized_list = valid_contract().replace( + "read:\n access: public\n disclosureProfile: public", + &format!( + "list:\n access: public\n disclosureProfile: public\n filters: []\n allowUnfiltered: true\n orderBy: [name]\n pagination: {{defaultPageSize: 1, maximumPageSize: {}}}", + MAXIMUM_LIST_PAGE_SIZE + 1 + ), + ); + let contract = RegistryContract::parse_yaml(&oversized_list).expect("strict list contract"); + let report = compile_contract_with_governed_files( + &contract, + &[observed_schema()], + CompileProfile::Production, + &governed_files(), + ) + .expect_err("oversized governed list is refused"); + assert!(report + .diagnostics + .iter() + .any(|item| item.code == "list.pagination_invalid")); + + let oversized_lookup = valid_contract().replace( + "read:\n access: public\n disclosureProfile: public", + &format!( + "lookups:\n - id: by-name\n access: {{scope: registry:records:lookup}}\n requestBody:\n maximumBytes: {}\n selectors:\n name: {{sourceColumn: name, type: string, maximumBytes: 32}}\n disclosureProfile: public", + MAXIMUM_LOOKUP_REQUEST_BODY_BYTES + 1 + ), + ); + let contract = + RegistryContract::parse_yaml(&oversized_lookup).expect("strict lookup contract"); + let report = compile_contract_with_governed_files( + &contract, + &[observed_schema()], + CompileProfile::Production, + &governed_files(), + ) + .expect_err("oversized governed lookup is refused"); + assert!(report + .diagnostics + .iter() + .any(|item| item.code == "lookup.body_bound_invalid")); + } + + #[test] + fn governed_structure_counts_cannot_exceed_product_ceilings() { + let parse_value = |value: serde_json::Value| { + serde_json::from_value::(value).expect("strict contract value") + }; + let assert_refused = |contract: &RegistryContract, code: &str| { + let report = + compile_contract(contract, &[observed_schema()], CompileProfile::Production) + .expect_err("oversized governed structure is refused"); + assert!( + report.diagnostics.iter().any(|item| item.code == code), + "missing {code} diagnostic in {:?}", + report.diagnostics + ); + }; + + let base = RegistryContract::parse_yaml(valid_contract()).expect("strict contract"); + let mut resources_value = serde_json::to_value(&base).expect("contract serializes"); + let resources = resources_value + .get_mut("resources") + .and_then(serde_json::Value::as_array_mut) + .expect("resources array"); + let resource = resources[0].clone(); + for index in 1..=MAXIMUM_RESOURCES { + let mut item = resource.clone(); + item.as_object_mut() + .expect("resource object") + .insert("id".into(), serde_json::json!(format!("record-{index}"))); + resources.push(item); + } + assert_refused(&parse_value(resources_value), "resource.bound_exceeded"); + + let mut properties_value = serde_json::to_value(&base).expect("contract serializes"); + let properties = properties_value + .pointer_mut("/resources/0/properties") + .and_then(serde_json::Value::as_object_mut) + .expect("properties object"); + let property = properties.get("name").expect("name property").clone(); + for index in 1..=MAXIMUM_PROPERTIES_PER_RESOURCE { + properties.insert(format!("name{index}"), property.clone()); + } + assert_refused(&parse_value(properties_value), "property.bound_exceeded"); + + let mut disclosures_value = serde_json::to_value(&base).expect("contract serializes"); + let disclosures = disclosures_value + .pointer_mut("/resources/0/disclosureProfiles") + .and_then(serde_json::Value::as_object_mut) + .expect("disclosures object"); + let disclosure = disclosures + .get("public") + .expect("public disclosure") + .clone(); + for index in 1..=MAXIMUM_DISCLOSURE_PROFILES_PER_RESOURCE { + disclosures.insert(format!("profile-{index}"), disclosure.clone()); + } + assert_refused(&parse_value(disclosures_value), "disclosure.bound_exceeded"); + + let list_yaml = valid_contract().replace( + "read:\n access: public\n disclosureProfile: public", + "list:\n access: public\n disclosureProfile: public\n filters: []\n allowUnfiltered: true\n orderBy: [name]\n pagination: {defaultPageSize: 1, maximumPageSize: 1}", + ); + let list_contract = RegistryContract::parse_yaml(&list_yaml).expect("strict list contract"); + let mut filters_value = serde_json::to_value(&list_contract).expect("contract serializes"); + let filters = filters_value + .pointer_mut("/resources/0/operations/list/filters") + .and_then(serde_json::Value::as_array_mut) + .expect("filters array"); + for index in 0..=MAXIMUM_LIST_FILTERS { + filters.push(serde_json::json!({ + "name": format!("filter{index}"), + "property": "name", + "type": "string", + })); + } + assert_refused(&parse_value(filters_value), "list.filter_bound_exceeded"); + + let mut order_value = serde_json::to_value(&list_contract).expect("contract serializes"); + let order = order_value + .pointer_mut("/resources/0/operations/list/orderBy") + .and_then(serde_json::Value::as_array_mut) + .expect("order array"); + *order = (0..=MAXIMUM_LIST_ORDER_KEYS) + .map(|_| serde_json::json!("name")) + .collect(); + assert_refused(&parse_value(order_value), "list.order_bound_exceeded"); + } + + #[test] + fn public_operation_cannot_process_nonpublic_columns() { + let yaml = valid_contract().replace( + "handling: public, status: reviewed", + "handling: internal, status: reviewed", + ); + let contract = RegistryContract::parse_yaml(&yaml).expect("strict contract"); + let report = compile_contract_with_governed_files( + &contract, + &[observed_schema()], + CompileProfile::Production, + &governed_files(), + ) + .expect_err("anonymous non-public processing is refused"); + assert!(report + .diagnostics + .iter() + .any(|item| item.code == "access.public_nonpublic_forbidden")); + } + + #[test] + fn row_authority_is_a_compiler_injected_lane_not_a_filter() { + let yaml = valid_contract().replace( + "access: public", + "access: {scope: registry:records:read, authorityRowBinding: {claim: region, sourceColumn: id}}", + ); + let contract = RegistryContract::parse_yaml(&yaml).expect("strict contract"); + let compiled = compile_contract_with_governed_files( + &contract, + &[observed_schema()], + CompileProfile::Production, + &governed_files(), + ) + .expect("protected row-bound compilation"); + let operation = &compiled.resources[0].operations[0]; + assert!(operation.query.filters.is_empty()); + let CompiledAccess::Protected { + row_binding: Some(binding), + .. + } = &operation.access + else { + panic!("row-bound protected access expected"); + }; + assert_eq!(binding.source_column, "id"); + assert_eq!(binding.source, RowAuthoritySource::Claim("region".into())); + let artifacts = crate::artifacts::generate_artifacts(&compiled).expect("artifacts"); + let public_openapi = artifacts + .get("openapi.public.json") + .expect("public OpenAPI"); + let openapi: serde_json::Value = + serde_json::from_slice(&public_openapi.content).expect("generated JSON"); + assert!(openapi["paths"] + .get("/v2/resources/record/records/{recordIdentifier}") + .is_none()); + } + + #[test] + fn public_record_cannot_reference_operator_only_semantics() { + let yaml = valid_contract().replace( + "resources: public, semantics: public", + "resources: public, semantics: operator-only", + ); + let contract = RegistryContract::parse_yaml(&yaml).expect("strict contract"); + let report = compile_contract_with_governed_files( + &contract, + &[observed_schema()], + CompileProfile::Production, + &governed_files(), + ) + .expect_err("unresolvable semantic reference is refused"); + assert!(report + .diagnostics + .iter() + .any(|item| item.code == "metadata.reference_visibility_invalid")); + } + + pub(crate) fn observed_schema() -> ObservedSourceSchema { + ObservedSourceSchema { + source: "db".into(), + fingerprint: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .into(), + views: vec![crate::model::ObservedView { + name: "registry_records".into(), + columns: ["id", "revision", "lifecycle", "recorded_at", "name"] + .into_iter() + .map(|name| crate::model::ObservedColumn { + name: name.into(), + declared_type: "TEXT".into(), + nullable: false, + primary_key: false, + }) + .collect(), + }], + } + } + + pub(crate) fn governed_files() -> GovernedFileSet { + [ + ( + "governance/identifier-lifecycle.yaml", + "status: reviewed\npolicy: identifiers are not reassigned\n", + ), + ( + "governance/classification-review.yaml", + "status: reviewed\nreviewer: registry-authority\n", + ), + ( + "governance/legal-basis.yaml", + "status: reviewed\nbasis: statutory-publication\n", + ), + ( + "governance/processing.dpv.yaml", + "status: reviewed\nprofile: https://w3id.org/dpv/2.3\n", + ), + ( + "codelists/record-lifecycle.yaml", + "id: record-lifecycle\nversion: 1\nvalues: [ACTIVE, RETIRED]\nstatus: reviewed\n", + ), + ] + .into_iter() + .map(|(path, content)| (path.into(), content.as_bytes().to_vec())) + .collect() + } + + pub(crate) fn valid_contract() -> &'static str { + r#"apiVersion: relay.registrystack.org/v2alpha1 +kind: RegistryContract +metadata: {id: records, version: "1", title: Records} +registry: + registryIdentifier: urn:example:registry:records + name: Records + authority: {identifier: urn:example:authority, name: Registry Authority} + authoritativeScope: Synthetic records + baseUri: https://registry.example.invalid/registry/ + identifierLifecyclePolicyRef: governance/identifier-lifecycle.yaml + alignmentTargets: + - {name: govstack-digital-registries, version: 3.0.0-alpha.2, status: directional} +governance: {controller: urn:example:authority, publisher: urn:example:authority, auditOwner: urn:example:audit} +semantics: {localVocabulary: https://registry.example.invalid/vocabulary/} +classifications: + privacy: {scheme: https://w3id.org/dpv, version: "2.3"} + institutional: {scheme: urn:example:classification, version: "1"} + handling: {scheme: https://id.registrystack.org/vocab/handling, version: "1"} + provenanceRef: governance/classification-review.yaml +sources: + db: {kind: sqlite, profile: snapshot, expectedSchemaFingerprint: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"} +resources: + - id: record + title: Record + description: One governed Record + semanticClass: local:Record + source: {source: db, view: registry_records} + classificationDefaults: {privacy: non-personal, institutional: public, handling: public, status: reviewed} + recordContext: + recordIdentifier: {sourceColumn: id} + revisionIdentifier: {sourceColumn: revision} + lifecycleState: {sourceColumn: lifecycle, codelist: codelists/record-lifecycle.yaml} + recordedAt: {sourceColumn: recorded_at} + sourceColumnClassifications: {} + properties: + name: + label: Name + description: Public Record name + sourceColumn: name + type: string + sourceRequired: true + semanticTerm: local:name + disclosureProfiles: {public: {properties: [name]}} + operations: + read: + access: public + disclosureProfile: public + processingDescriptions: + - id: statutory-publication + operationRefs: [read] + purpose: statutory-publication + recipientClass: public + legalBasisRef: governance/legal-basis.yaml + dpvProfileRef: governance/processing.dpv.yaml + safeguards: [property-minimization] +metadataVisibility: {service: public, resources: public, semantics: public, classifications: public, processing: public} +"# + } +} diff --git a/crates/registry-relay-v2/src/contract.rs b/crates/registry-relay-v2/src/contract.rs new file mode 100644 index 000000000..7de7fdd45 --- /dev/null +++ b/crates/registry-relay-v2/src/contract.rs @@ -0,0 +1,792 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Strict governed and deployment input contracts. + +use std::collections::HashSet; +use std::fmt; +use std::net::SocketAddr; +use std::ops::Deref; + +use serde::de::{self, MapAccess, Visitor}; +use serde::ser::SerializeMap; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use thiserror::Error; + +/// A duplicate-free insertion-ordered YAML mapping. +/// +/// Property and selector order is authored behavior, while ordinary map +/// containers would erase both duplicate keys and order before compilation. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct OrderedMap(Vec<(String, T)>); + +impl OrderedMap { + pub fn iter(&self) -> impl Iterator { + self.0.iter().map(|(key, value)| (key.as_str(), value)) + } + + pub fn get(&self, key: &str) -> Option<&T> { + self.0 + .iter() + .find_map(|(candidate, value)| (candidate == key).then_some(value)) + } + + pub fn keys(&self) -> impl Iterator { + self.0.iter().map(|(key, _)| key.as_str()) + } + + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + pub fn len(&self) -> usize { + self.0.len() + } +} + +impl Deref for OrderedMap { + type Target = [(String, T)]; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl Serialize for OrderedMap { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let mut map = serializer.serialize_map(Some(self.0.len()))?; + for (key, value) in &self.0 { + map.serialize_entry(key, value)?; + } + map.end() + } +} + +struct OrderedMapVisitor(std::marker::PhantomData); + +impl<'de, T: Deserialize<'de>> Visitor<'de> for OrderedMapVisitor { + type Value = OrderedMap; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a mapping with unique string keys") + } + + fn visit_map(self, mut access: A) -> Result + where + A: MapAccess<'de>, + { + let mut entries = Vec::with_capacity(access.size_hint().unwrap_or(0)); + let mut names = HashSet::with_capacity(access.size_hint().unwrap_or(0)); + while let Some((key, value)) = access.next_entry::()? { + if !names.insert(key.clone()) { + return Err(de::Error::custom(format_args!( + "duplicate mapping key `{key}`" + ))); + } + entries.push((key, value)); + } + Ok(OrderedMap(entries)) + } +} + +impl<'de, T: Deserialize<'de>> Deserialize<'de> for OrderedMap { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserializer.deserialize_map(OrderedMapVisitor(std::marker::PhantomData)) + } +} + +#[derive(Debug, Error)] +#[error("contract YAML is not valid")] +pub struct ContractParseError { + #[source] + source: serde_norway::Error, +} + +impl ContractParseError { + pub fn detail(&self) -> &serde_norway::Error { + &self.source + } +} + +/// Governed Relay-owned Registry input. Unknown fields are rejected at every +/// nested structure rather than silently becoming deployment behavior. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct RegistryContract { + pub api_version: String, + pub kind: String, + pub metadata: ContractMetadata, + pub registry: RegistryDefinition, + pub governance: Governance, + pub semantics: Semantics, + pub classifications: ClassificationCatalog, + pub sources: OrderedMap, + pub resources: Vec, + pub metadata_visibility: MetadataVisibility, +} + +impl RegistryContract { + pub fn parse_yaml(input: &str) -> Result { + serde_norway::from_str(input).map_err(|source| ContractParseError { source }) + } +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ContractMetadata { + pub id: String, + pub version: String, + pub title: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct RegistryDefinition { + pub registry_identifier: String, + pub name: String, + pub authority: Institution, + #[serde(default)] + pub operator: Option, + pub authoritative_scope: String, + pub base_uri: String, + pub identifier_lifecycle_policy_ref: String, + pub alignment_targets: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct Institution { + pub identifier: String, + pub name: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct AlignmentTarget { + pub name: String, + pub version: String, + #[serde(default)] + pub cfr_target: Option, + pub status: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct Governance { + pub controller: String, + pub publisher: String, + pub audit_owner: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct Semantics { + pub local_vocabulary: String, + #[serde(default)] + pub alignments: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct SemanticAlignment { + pub id: String, + pub version: String, + pub profile_ref: String, + pub digest: String, + pub relation_required: bool, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ClassificationCatalog { + pub privacy: SchemeVersion, + pub institutional: SchemeVersion, + pub handling: SchemeVersion, + pub provenance_ref: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct SchemeVersion { + pub scheme: String, + pub version: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct SourceDefinition { + pub kind: String, + pub profile: SourceProfile, + pub expected_schema_fingerprint: String, +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum SourceProfile { + Snapshot, + LiveReadOnly, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ResourceDefinition { + pub id: String, + pub title: String, + pub description: String, + pub semantic_class: String, + pub source: ResourceSource, + pub classification_defaults: ClassificationPartial, + pub record_context: RecordContext, + #[serde(default)] + pub source_column_classifications: OrderedMap, + pub properties: OrderedMap, + pub disclosure_profiles: OrderedMap, + pub operations: Operations, + #[serde(default)] + pub processing_descriptions: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ResourceSource { + pub source: String, + pub view: String, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ClassificationPartial { + #[serde(default)] + pub privacy: Option, + #[serde(default)] + pub institutional: Option, + #[serde(default)] + pub handling: Option, + #[serde(default)] + pub status: Option, +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "kebab-case")] +pub enum Handling { + Public, + Internal, + Confidential, + Restricted, +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum ReviewStatus { + Reviewed, + Suggested, + Uncertain, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct RecordContext { + pub record_identifier: ColumnBinding, + pub revision_identifier: ColumnBinding, + pub lifecycle_state: CodelistColumnBinding, + pub recorded_at: ColumnBinding, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ColumnBinding { + pub source_column: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct CodelistColumnBinding { + pub source_column: String, + pub codelist: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct PropertyDefinition { + pub label: String, + pub description: String, + pub source_column: String, + #[serde(rename = "type")] + pub data_type: DataType, + #[serde(default)] + pub codelist: Option, + pub source_required: bool, + pub semantic_term: String, + #[serde(default)] + pub classification: ClassificationPartial, +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum DataType { + String, + Boolean, + Integer, + Date, + DateTime, + ControlledCode, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct DisclosureProfile { + pub properties: Vec, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct Operations { + #[serde(default)] + pub list: Option, + #[serde(default)] + pub read: Option, + #[serde(default)] + pub lookups: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ListOperation { + pub access: AccessRule, + pub disclosure_profile: String, + #[serde(default)] + pub filters: Vec, + pub allow_unfiltered: bool, + pub order_by: Vec, + pub pagination: Pagination, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct RecordOperation { + pub access: AccessRule, + pub disclosure_profile: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct LookupOperation { + pub id: String, + pub access: AccessRule, + pub request_body: LookupRequestBody, + pub disclosure_profile: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(untagged)] +pub enum AccessRule { + Public(String), + Protected(ProtectedAccess), +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ProtectedAccess { + pub scope: String, + #[serde(default)] + pub purpose: Option, + #[serde(default)] + pub authority_row_binding: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct PurposeConstraint { + pub claim: String, + pub allowed: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(untagged)] +pub enum AuthorityRowBinding { + Claim(ClaimRowBinding), + Principal(PrincipalRowBinding), +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ClaimRowBinding { + pub claim: String, + pub source_column: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct PrincipalRowBinding { + pub principal: bool, + pub source_column: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct FilterDefinition { + pub name: String, + pub property: String, + #[serde(rename = "type")] + pub data_type: DataType, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct Pagination { + pub default_page_size: u32, + pub maximum_page_size: u32, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct LookupRequestBody { + pub maximum_bytes: u32, + pub selectors: OrderedMap, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct SelectorDefinition { + pub source_column: String, + #[serde(rename = "type")] + pub data_type: DataType, + #[serde(default)] + pub minimum_bytes: Option, + #[serde(default)] + pub maximum_bytes: Option, + #[serde(default)] + pub codelist: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ProcessingDescription { + pub id: String, + pub operation_refs: Vec, + pub purpose: String, + pub recipient_class: String, + pub legal_basis_ref: String, + pub dpv_profile_ref: String, + pub safeguards: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct MetadataVisibility { + pub service: Visibility, + pub resources: Visibility, + pub semantics: Visibility, + pub classifications: Visibility, + pub processing: Visibility, +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum Visibility { + Public, + OperationBound, + OperatorOnly, +} + +/// Deployment-local bindings. No governed field is accepted here. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct RelayRuntime { + pub api_version: String, + pub kind: String, + pub server: ServerRuntime, + pub package_path: String, + pub sources: OrderedMap, + pub authentication: AuthenticationRuntime, + pub audit: AuditRuntime, + #[serde(default)] + pub cursor: Option, + pub limits: RuntimeLimits, + #[serde(default)] + pub quotas: Option, + #[serde(default)] + pub shutdown: Option, +} + +impl RelayRuntime { + pub fn parse_yaml(input: &str) -> Result { + let runtime: Self = + serde_norway::from_str(input).map_err(|source| ContractParseError { source })?; + if runtime.is_valid() { + Ok(runtime) + } else { + Err(ContractParseError { + source: ::custom( + "the deployment binding violates the closed runtime profile", + ), + }) + } + } + + fn is_valid(&self) -> bool { + if self.api_version != "relay.registrystack.org/v2alpha1" + || self.kind != "RelayRuntime" + || self.server.bind.parse::().is_err() + || self.package_path.trim().is_empty() + || self.sources.is_empty() + || self.audit.sink.trim().is_empty() + || !valid_secret_reference(&self.audit.integrity_key_ref) + || self.limits.request_timeout_milliseconds == 0 + || self.limits.request_timeout_milliseconds > 120_000 + || self.limits.concurrent_queries == 0 + || self.limits.concurrent_queries > 256 + { + return false; + } + if self + .sources + .iter() + .any(|(id, source)| !valid_runtime_id(id) || source.path.trim().is_empty()) + { + return false; + } + if self.cursor.as_ref().is_some_and(|cursor| { + !valid_secret_reference(&cursor.integrity_key_ref) + || cursor.maximum_age_seconds == 0 + || cursor.maximum_age_seconds > 86_400 + }) { + return false; + } + if self.quotas.as_ref().is_some_and(|quota| { + quota.requests_per_minute == 0 || quota.burst == 0 || quota.burst > 100_000 + }) { + return false; + } + if self + .shutdown + .as_ref() + .is_some_and(|shutdown| shutdown.grace_period_milliseconds == 0) + { + return false; + } + self.authentication.issuer.as_ref().is_none_or(|issuer| { + valid_runtime_id(&issuer.id) + && UrlLike::https(&issuer.discovery_url) + && !issuer.audience.trim().is_empty() + && !issuer.token_types.is_empty() + && !issuer.algorithms.is_empty() + && unique_nonempty(&issuer.token_types) + && unique_nonempty(&issuer.algorithms) + && issuer.token_types.iter().all(|value| value == "at+jwt") + && issuer + .algorithms + .iter() + .all(|value| matches!(value.as_str(), "EdDSA" | "ES256" | "RS256")) + }) + } +} + +struct UrlLike; + +impl UrlLike { + fn https(value: &str) -> bool { + value.starts_with("https://") + && value.len() > "https://".len() + && !value.chars().any(char::is_whitespace) + } +} + +fn valid_secret_reference(value: &str) -> bool { + if let Some(name) = value.strip_prefix("secret:env/") { + let bytes = name.as_bytes(); + return matches!(bytes.first(), Some(b'A'..=b'Z')) + && bytes.len() <= 128 + && bytes[1..] + .iter() + .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit() || *byte == b'_'); + } + if let Some(name) = value.strip_prefix("secret:file/") { + let bytes = name.as_bytes(); + return matches!(bytes.first(), Some(b'a'..=b'z')) + && bytes.len() <= 128 + && bytes[1..].iter().all(|byte| { + byte.is_ascii_lowercase() + || byte.is_ascii_digit() + || matches!(byte, b'.' | b'_' | b'-') + }); + } + false +} + +fn valid_runtime_id(value: &str) -> bool { + !value.is_empty() + && !value.starts_with('-') + && !value.ends_with('-') + && value + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') +} + +fn unique_nonempty(values: &[String]) -> bool { + let mut seen = HashSet::new(); + values + .iter() + .all(|value| !value.trim().is_empty() && seen.insert(value.as_str())) +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ServerRuntime { + pub bind: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct RuntimeSource { + pub path: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct AuthenticationRuntime { + pub issuer: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct IssuerRuntime { + pub id: String, + pub discovery_url: String, + pub audience: String, + pub token_types: Vec, + pub algorithms: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct AuditRuntime { + pub sink: String, + pub integrity_key_ref: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct CursorRuntime { + pub integrity_key_ref: String, + pub maximum_age_seconds: u64, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct RuntimeLimits { + pub request_timeout_milliseconds: u64, + pub concurrent_queries: u32, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct QuotaRuntime { + pub requests_per_minute: u32, + pub burst: u32, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ShutdownRuntime { + pub grace_period_milliseconds: u64, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ordered_map_rejects_duplicate_property_keys() { + let input = r#" +apiVersion: relay.registrystack.org/v2alpha1 +kind: RegistryContract +metadata: {id: x, version: v1, title: X} +registry: + registryIdentifier: urn:x + name: X + authority: {identifier: urn:a, name: A} + authoritativeScope: scope + baseUri: https://example.invalid/ + identifierLifecyclePolicyRef: governance/id.yaml + alignmentTargets: [] +governance: {controller: urn:a, publisher: urn:a, auditOwner: urn:a} +semantics: {localVocabulary: https://example.invalid/vocab/} +classifications: + privacy: {scheme: urn:p, version: "1"} + institutional: {scheme: urn:i, version: "1"} + handling: {scheme: urn:h, version: "1"} + provenanceRef: governance/review.yaml +sources: + db: {kind: sqlite, profile: snapshot, expectedSchemaFingerprint: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"} +resources: + - id: thing + title: Thing + description: Thing + semanticClass: local:Thing + source: {source: db, view: things} + classificationDefaults: {privacy: public, institutional: public, handling: public, status: reviewed} + recordContext: + recordIdentifier: {sourceColumn: id} + revisionIdentifier: {sourceColumn: rev} + lifecycleState: {sourceColumn: state, codelist: state.yaml} + recordedAt: {sourceColumn: recorded_at} + properties: + name: {label: Name, description: Name, sourceColumn: name, type: string, sourceRequired: true, semanticTerm: "local:name"} + name: {label: Other, description: Other, sourceColumn: other, type: string, sourceRequired: true, semanticTerm: "local:other"} + disclosureProfiles: {default: {properties: [name]}} + operations: {read: {access: public, disclosureProfile: default}} +metadataVisibility: {service: public, resources: public, semantics: public, classifications: public, processing: public} +"#; + + assert!(RegistryContract::parse_yaml(input).is_err()); + } + + #[test] + fn runtime_rejects_governed_override() { + let input = r#" +apiVersion: relay.registrystack.org/v2alpha1 +kind: RelayRuntime +server: {bind: "127.0.0.1:8080"} +packagePath: /srv/relay/package +sources: {db: {path: /srv/registry.sqlite}} +authentication: {issuer: null} +audit: {sink: /var/log/relay.jsonl, integrityKeyRef: secret:key} +limits: {requestTimeoutMilliseconds: 1000, concurrentQueries: 4} +disclosureProfiles: {} +"#; + assert!(RelayRuntime::parse_yaml(input).is_err()); + } + + #[test] + fn runtime_accepts_only_the_supported_secret_reference_grammars() { + let template = |reference: &str| { + format!( + "apiVersion: relay.registrystack.org/v2alpha1\nkind: RelayRuntime\nserver: {{bind: '127.0.0.1:8080'}}\npackagePath: /srv/relay/package\nsources: {{db: {{path: /srv/registry.sqlite}}}}\nauthentication: {{issuer: null}}\naudit: {{sink: /var/log/relay.jsonl, integrityKeyRef: {reference}}}\nlimits: {{requestTimeoutMilliseconds: 1000, concurrentQueries: 4}}\n" + ) + }; + for valid in ["secret:env/RELAY_KEY", "secret:file/audit-integrity-key"] { + assert!( + RelayRuntime::parse_yaml(&template(valid)).is_ok(), + "{valid}" + ); + } + for invalid in [ + "secret:key", + "secret:env/lowercase", + "secret:env/KEY/value", + "secret:file/../key", + "secret:file/nested/key", + "secret:vault/key", + ] { + assert!( + RelayRuntime::parse_yaml(&template(invalid)).is_err(), + "{invalid}" + ); + } + } +} diff --git a/crates/registry-relay-v2/src/cursor.rs b/crates/registry-relay-v2/src/cursor.rs new file mode 100644 index 000000000..c9f25c549 --- /dev/null +++ b/crates/registry-relay-v2/src/cursor.rs @@ -0,0 +1,276 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Opaque, integrity-protected keyset cursors. + +use std::collections::BTreeMap; +use std::fmt; +use std::time::{SystemTime, UNIX_EPOCH}; + +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine as _; +use hmac::{Hmac, KeyInit, Mac}; +use serde::{Deserialize, Serialize}; +use sha2::Sha256; +use thiserror::Error; +use zeroize::Zeroizing; + +const CURSOR_VERSION: u8 = 1; +const MAX_CURSOR_BYTES: usize = 8 * 1024; +const MAC_BYTES: usize = 32; + +type HmacSha256 = Hmac; + +/// All request properties which must stay fixed across a keyset page chain. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CursorPayload { + pub version: u8, + pub expires_at_unix_seconds: u64, + pub contract_revision: String, + pub source_revision: String, + pub operation: String, + pub filters_digest: String, + pub selected_fields_digest: String, + pub authorization_digest: String, + pub order_digest: String, + pub last_record_identifier: String, + #[serde(default)] + pub page_size: u32, + #[serde(default)] + pub filters: BTreeMap, + #[serde(default)] + pub selected_fields: Vec, + #[serde(default)] + pub last_order_values: Vec, +} + +/// Closed scalar set carried by a cursor. Collection filters are non-personal +/// and exact-match-only; row authority remains represented solely by its +/// digest and is freshly derived from the verified token on every page. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(untagged)] +pub enum CursorValue { + String(String), + Integer(i64), + Boolean(bool), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CursorBindings { + pub filters_digest: String, + pub selected_fields_digest: String, + pub authorization_digest: String, + pub order_digest: String, + pub last_record_identifier: String, +} + +impl CursorPayload { + #[must_use] + pub fn new( + expires_at_unix_seconds: u64, + contract_revision: String, + source_revision: String, + operation: String, + bindings: CursorBindings, + ) -> Self { + Self { + version: CURSOR_VERSION, + expires_at_unix_seconds, + contract_revision, + source_revision, + operation, + filters_digest: bindings.filters_digest, + selected_fields_digest: bindings.selected_fields_digest, + authorization_digest: bindings.authorization_digest, + order_digest: bindings.order_digest, + last_record_identifier: bindings.last_record_identifier, + page_size: 0, + filters: BTreeMap::new(), + selected_fields: Vec::new(), + last_order_values: Vec::new(), + } + } + + #[must_use] + pub fn with_query_context( + mut self, + page_size: u32, + filters: BTreeMap, + selected_fields: Vec, + last_order_values: Vec, + ) -> Self { + self.page_size = page_size; + self.filters = filters; + self.selected_fields = selected_fields; + self.last_order_values = last_order_values; + self + } +} + +/// Cursor HMAC key. `Debug` intentionally cannot expose key material. +pub struct CursorKey(Zeroizing>); + +impl CursorKey { + pub fn new(bytes: Vec) -> Result { + if bytes.len() < MAC_BYTES { + return Err(CursorError::Configuration); + } + Ok(Self(Zeroizing::new(bytes))) + } + + /// Domain-separated binding used for authorization and query-context + /// commitments embedded in a cursor. + pub fn binding_digest(&self, domain: &[u8], value: &[u8]) -> Result { + let mut mac = HmacSha256::new_from_slice(self.0.as_slice()) + .map_err(|_| CursorError::Configuration)?; + mac.update(b"registry-relay-v2-cursor-binding-v1\0"); + mac.update(domain); + mac.update(&[0]); + mac.update(value); + Ok(format!( + "hmac-sha256:{}", + hex::encode(mac.finalize().into_bytes()) + )) + } +} + +impl fmt::Debug for CursorKey { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("CursorKey()") + } +} + +#[derive(Clone, Copy, Debug, Error, PartialEq, Eq)] +pub enum CursorError { + #[error("cursor configuration is invalid")] + Configuration, + #[error("cursor is malformed")] + Malformed, + #[error("cursor signature is invalid")] + Integrity, + #[error("cursor is expired")] + Expired, + #[error("cursor does not match this request")] + Mismatch, +} + +pub fn encode(key: &CursorKey, payload: &CursorPayload) -> Result { + let encoded = serde_json::to_vec(payload).map_err(|_| CursorError::Malformed)?; + if encoded.is_empty() || encoded.len() > MAX_CURSOR_BYTES { + return Err(CursorError::Malformed); + } + let mut mac = + HmacSha256::new_from_slice(key.0.as_slice()).map_err(|_| CursorError::Configuration)?; + mac.update(&encoded); + let signature = mac.finalize().into_bytes(); + let mut envelope = Vec::with_capacity(encoded.len() + MAC_BYTES); + envelope.extend_from_slice(&encoded); + envelope.extend_from_slice(&signature); + Ok(URL_SAFE_NO_PAD.encode(envelope)) +} + +pub fn decode( + key: &CursorKey, + encoded: &str, + now_unix_seconds: u64, +) -> Result { + if encoded.is_empty() || encoded.len() > MAX_CURSOR_BYTES * 2 { + return Err(CursorError::Malformed); + } + let envelope = URL_SAFE_NO_PAD + .decode(encoded) + .map_err(|_| CursorError::Malformed)?; + if envelope.len() <= MAC_BYTES || envelope.len() > MAX_CURSOR_BYTES + MAC_BYTES { + return Err(CursorError::Malformed); + } + let (payload_bytes, supplied_signature) = envelope.split_at(envelope.len() - MAC_BYTES); + let mut mac = + HmacSha256::new_from_slice(key.0.as_slice()).map_err(|_| CursorError::Configuration)?; + mac.update(payload_bytes); + mac.verify_slice(supplied_signature) + .map_err(|_| CursorError::Integrity)?; + let payload: CursorPayload = + serde_json::from_slice(payload_bytes).map_err(|_| CursorError::Malformed)?; + if payload.version != CURSOR_VERSION { + return Err(CursorError::Malformed); + } + if payload.expires_at_unix_seconds <= now_unix_seconds { + return Err(CursorError::Expired); + } + Ok(payload) +} + +/// Compare all request-bound fields except the last keyset value. +pub fn require_same_request( + cursor: &CursorPayload, + request: &CursorPayload, +) -> Result<(), CursorError> { + if cursor.contract_revision != request.contract_revision + || cursor.source_revision != request.source_revision + || cursor.operation != request.operation + || cursor.filters_digest != request.filters_digest + || cursor.selected_fields_digest != request.selected_fields_digest + || cursor.authorization_digest != request.authorization_digest + || cursor.order_digest != request.order_digest + { + return Err(CursorError::Mismatch); + } + Ok(()) +} + +#[must_use] +pub fn now_unix_seconds() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn payload() -> CursorPayload { + CursorPayload::new( + 100, + "sha256:contract".to_owned(), + "sha256:source".to_owned(), + "resource.list".to_owned(), + CursorBindings { + filters_digest: "sha256:filters".to_owned(), + selected_fields_digest: "sha256:fields".to_owned(), + authorization_digest: "sha256:authorization".to_owned(), + order_digest: "sha256:order".to_owned(), + last_record_identifier: "record-1".to_owned(), + }, + ) + } + + #[test] + fn cursor_is_opaque_and_refuses_tampering() { + let key = CursorKey::new(vec![7; 32]).expect("key is sufficient"); + let encoded = encode(&key, &payload()).expect("cursor encodes"); + assert!(!encoded.contains("record-1")); + let mut tampered = encoded.into_bytes(); + let final_byte = tampered.len() - 1; + tampered[final_byte] = if tampered[final_byte] == b'A' { + b'B' + } else { + b'A' + }; + let tampered = String::from_utf8(tampered).expect("cursor stays text"); + assert!(matches!( + decode(&key, &tampered, 1), + Err(CursorError::Integrity) | Err(CursorError::Malformed) + )); + } + + #[test] + fn cursor_cannot_cross_authorization_or_filter_contexts() { + let mut request = payload(); + request.authorization_digest = "sha256:other".to_owned(); + assert_eq!( + require_same_request(&payload(), &request), + Err(CursorError::Mismatch) + ); + } +} diff --git a/crates/registry-relay-v2/src/diff.rs b/crates/registry-relay-v2/src/diff.rs new file mode 100644 index 000000000..0febccfe4 --- /dev/null +++ b/crates/registry-relay-v2/src/diff.rs @@ -0,0 +1,954 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Authoritative semantic, disclosure, and security change classification. + +use std::collections::{BTreeMap, BTreeSet}; + +use serde::{Deserialize, Serialize}; + +use crate::contract::Visibility; +use crate::model::{CompiledAccess, CompiledOperation, CompiledRegistry, CompiledResource}; + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ChangeImpactReport { + pub previous_revision: String, + pub current_revision: String, + pub changes: Vec, +} + +impl ChangeImpactReport { + pub fn has_disclosure_or_access_widening(&self) -> bool { + self.changes + .iter() + .any(|change| change.impact == ChangeImpact::Widening) + } +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ContractChange { + pub class: ChangeClass, + pub impact: ChangeImpact, + pub location: String, + pub description: String, +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "kebab-case")] +pub enum ChangeImpact { + Informational, + Narrowing, + Widening, + Breaking, +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "kebab-case")] +pub enum ChangeClass { + ResourceAdded, + ResourceRemoved, + PropertyAdded, + PropertyRemoved, + PropertyMeaningChanged, + HandlingRelaxed, + HandlingTightened, + OperationAdded, + OperationRemoved, + DisclosureExpanded, + DisclosureNarrowed, + DisclosureProfileChanged, + FilterAdded, + FilterRemoved, + FilterChanged, + UnfilteredEnabled, + UnfilteredDisabled, + SelectorChanged, + OrderingChanged, + PaginationExpanded, + PaginationNarrowed, + RequestBoundExpanded, + RequestBoundNarrowed, + ScopeChanged, + PurposeExpanded, + PurposeNarrowed, + RowBindingRemoved, + RowBindingAdded, + RowBindingChanged, + SourceViewChanged, + SourceSchemaChanged, + RecordContextChanged, + MetadataVisibilityRelaxed, + MetadataVisibilityTightened, + SemanticAlignmentChanged, + ClassificationChanged, + ProcessingChanged, + GovernedFileChanged, +} + +pub fn diff_registries( + previous: &CompiledRegistry, + current: &CompiledRegistry, +) -> ChangeImpactReport { + let mut changes = Vec::new(); + let previous_sources = previous + .sources + .iter() + .map(|source| (source.id.as_str(), source)) + .collect::>(); + let current_sources = current + .sources + .iter() + .map(|source| (source.id.as_str(), source)) + .collect::>(); + for id in previous_sources + .keys() + .chain(current_sources.keys()) + .collect::>() + { + match (previous_sources.get(*id), current_sources.get(*id)) { + (Some(before), Some(after)) + if before.profile != after.profile + || before.expected_schema_fingerprint != after.expected_schema_fingerprint => + { + push( + &mut changes, + ChangeClass::SourceSchemaChanged, + ChangeImpact::Breaking, + format!("sources.{id}"), + "the governed source profile or expected schema fingerprint changed", + ); + } + (None, Some(_)) | (Some(_), None) => push( + &mut changes, + ChangeClass::SourceSchemaChanged, + ChangeImpact::Breaking, + format!("sources.{id}"), + "a governed source binding was added or removed", + ), + _ => {} + } + } + let previous_resources = resource_map(previous); + let current_resources = resource_map(current); + + for id in previous_resources + .keys() + .chain(current_resources.keys()) + .collect::>() + { + match (previous_resources.get(*id), current_resources.get(*id)) { + (None, Some(_)) => push( + &mut changes, + ChangeClass::ResourceAdded, + ChangeImpact::Widening, + format!("resources.{id}"), + "a published resource was added", + ), + (Some(_), None) => push( + &mut changes, + ChangeClass::ResourceRemoved, + ChangeImpact::Breaking, + format!("resources.{id}"), + "a published resource was removed", + ), + (Some(before), Some(after)) => diff_resource(before, after, &mut changes), + (None, None) => unreachable!(), + } + } + + diff_visibility(previous, current, &mut changes); + if previous.semantic_alignments != current.semantic_alignments { + push( + &mut changes, + ChangeClass::SemanticAlignmentChanged, + ChangeImpact::Informational, + "semantics.alignments".into(), + "the pinned external semantic alignment set changed", + ); + } + let before_governed = previous + .governed_files + .iter() + .map(|file| (file.path.as_str(), file)) + .collect::>(); + let after_governed = current + .governed_files + .iter() + .map(|file| (file.path.as_str(), file)) + .collect::>(); + for path in before_governed + .keys() + .chain(after_governed.keys()) + .collect::>() + { + if before_governed.get(*path) != after_governed.get(*path) { + push( + &mut changes, + ChangeClass::GovernedFileChanged, + ChangeImpact::Breaking, + format!("governedFiles.{path}"), + "a governed sidecar digest or referenced role changed", + ); + } + } + changes.sort_by(|left, right| { + left.location + .cmp(&right.location) + .then(left.class.cmp(&right.class)) + .then(left.impact.cmp(&right.impact)) + }); + ChangeImpactReport { + previous_revision: previous.contract_revision.clone(), + current_revision: current.contract_revision.clone(), + changes, + } +} + +fn resource_map(registry: &CompiledRegistry) -> BTreeMap<&str, &CompiledResource> { + registry + .resources + .iter() + .map(|resource| (resource.id.as_str(), resource)) + .collect() +} + +fn diff_resource( + previous: &CompiledResource, + current: &CompiledResource, + changes: &mut Vec, +) { + let root = format!("resources.{}", current.id); + if previous.source != current.source || previous.view != current.view { + push( + changes, + ChangeClass::SourceViewChanged, + ChangeImpact::Breaking, + format!("{root}.source"), + "the reviewed source or view changed", + ); + } + if previous.record_context != current.record_context { + push( + changes, + ChangeClass::RecordContextChanged, + ChangeImpact::Breaking, + format!("{root}.recordContext"), + "a Registry Core binding or reference changed", + ); + } + if previous.column_accounting != current.column_accounting { + push( + changes, + ChangeClass::ClassificationChanged, + ChangeImpact::Breaking, + format!("{root}.sourceColumnClassifications"), + "effective classifications or uses of reviewed source columns changed", + ); + } + if previous.processing_descriptions != current.processing_descriptions { + push( + changes, + ChangeClass::ProcessingChanged, + ChangeImpact::Breaking, + format!("{root}.processingDescriptions"), + "the reviewed processing description set changed", + ); + } + + let before_properties = previous + .properties + .iter() + .map(|property| (property.name.as_str(), property)) + .collect::>(); + let after_properties = current + .properties + .iter() + .map(|property| (property.name.as_str(), property)) + .collect::>(); + for name in before_properties + .keys() + .chain(after_properties.keys()) + .collect::>() + { + let location = format!("{root}.properties.{name}"); + match (before_properties.get(*name), after_properties.get(*name)) { + (None, Some(_)) => push( + changes, + ChangeClass::PropertyAdded, + ChangeImpact::Widening, + location, + "a publishable property was added", + ), + (Some(_), None) => push( + changes, + ChangeClass::PropertyRemoved, + ChangeImpact::Breaking, + location, + "a publishable property was removed", + ), + (Some(before), Some(after)) => { + if before.semantic_iri != after.semantic_iri + || before.data_type != after.data_type + || before.codelist != after.codelist + || before.source_column != after.source_column + || before.source_required != after.source_required + { + push( + changes, + ChangeClass::PropertyMeaningChanged, + ChangeImpact::Breaking, + location.clone(), + "a property binding, meaning, datatype, codelist, or requiredness changed", + ); + } + let before_handling = before.classification.handling; + let after_handling = after.classification.handling; + if after_handling < before_handling { + push( + changes, + ChangeClass::HandlingRelaxed, + ChangeImpact::Widening, + format!("{location}.classification.handling"), + "technical handling became less restrictive", + ); + } else if after_handling > before_handling { + push( + changes, + ChangeClass::HandlingTightened, + ChangeImpact::Narrowing, + format!("{location}.classification.handling"), + "technical handling became more restrictive", + ); + } + if classification_context(&before.classification) + != classification_context(&after.classification) + { + push( + changes, + ChangeClass::ClassificationChanged, + ChangeImpact::Breaking, + format!("{location}.classification"), + "privacy, institutional, review, scheme, version, or provenance classification changed", + ); + } + } + (None, None) => unreachable!(), + } + } + + let before_operations = operation_map(previous); + let after_operations = operation_map(current); + for id in before_operations + .keys() + .chain(after_operations.keys()) + .collect::>() + { + let location = format!("{root}.operations.{id}"); + match (before_operations.get(*id), after_operations.get(*id)) { + (None, Some(_)) => push( + changes, + ChangeClass::OperationAdded, + ChangeImpact::Widening, + location, + "a consultation operation was added", + ), + (Some(_), None) => push( + changes, + ChangeClass::OperationRemoved, + ChangeImpact::Breaking, + location, + "a consultation operation was removed", + ), + (Some(before), Some(after)) => diff_operation(before, after, &location, changes), + (None, None) => unreachable!(), + } + } +} + +fn operation_map(resource: &CompiledResource) -> BTreeMap<&str, &CompiledOperation> { + resource + .operations + .iter() + .map(|operation| (operation.identifier.as_str(), operation)) + .collect() +} + +fn diff_operation( + previous: &CompiledOperation, + current: &CompiledOperation, + location: &str, + changes: &mut Vec, +) { + if previous.disclosure_profile != current.disclosure_profile { + push( + changes, + ChangeClass::DisclosureProfileChanged, + ChangeImpact::Breaking, + format!("{location}.disclosureProfile"), + "the named disclosure profile changed and requires review", + ); + } + let previous_properties = previous + .selectable_properties + .iter() + .map(String::as_str) + .collect::>(); + let current_properties = current + .selectable_properties + .iter() + .map(String::as_str) + .collect::>(); + if current_properties + .difference(&previous_properties) + .next() + .is_some() + { + push( + changes, + ChangeClass::DisclosureExpanded, + ChangeImpact::Widening, + format!("{location}.disclosureProfile"), + "the maximum disclosure property set expanded", + ); + } + if previous_properties + .difference(¤t_properties) + .next() + .is_some() + { + push( + changes, + ChangeClass::DisclosureNarrowed, + ChangeImpact::Narrowing, + format!("{location}.disclosureProfile"), + "the maximum disclosure property set narrowed", + ); + } + + let before_filters = previous + .query + .filters + .iter() + .map(|filter| (filter.parameter.as_str(), filter)) + .collect::>(); + let after_filters = current + .query + .filters + .iter() + .map(|filter| (filter.parameter.as_str(), filter)) + .collect::>(); + for name in before_filters + .keys() + .chain(after_filters.keys()) + .collect::>() + { + match (before_filters.get(*name), after_filters.get(*name)) { + (None, Some(_)) => push( + changes, + ChangeClass::FilterAdded, + ChangeImpact::Widening, + format!("{location}.filters.{name}"), + "a collection filter was added", + ), + (Some(_), None) => push( + changes, + ChangeClass::FilterRemoved, + ChangeImpact::Breaking, + format!("{location}.filters.{name}"), + "a collection filter was removed", + ), + (Some(before), Some(after)) if before != after => push( + changes, + ChangeClass::FilterChanged, + ChangeImpact::Breaking, + format!("{location}.filters.{name}"), + "a filter property, source binding, or datatype changed", + ), + _ => {} + } + } + match ( + previous.query.allow_unfiltered, + current.query.allow_unfiltered, + ) { + (false, true) => push( + changes, + ChangeClass::UnfilteredEnabled, + ChangeImpact::Widening, + format!("{location}.allowUnfiltered"), + "unfiltered collection access was enabled", + ), + (true, false) => push( + changes, + ChangeClass::UnfilteredDisabled, + ChangeImpact::Narrowing, + format!("{location}.allowUnfiltered"), + "unfiltered collection access was disabled", + ), + _ => {} + } + if previous.query.selectors != current.query.selectors { + push( + changes, + ChangeClass::SelectorChanged, + ChangeImpact::Breaking, + format!("{location}.selectors"), + "lookup selector names, bindings, types, bounds, or codelists changed", + ); + } + if previous.query.order_by != current.query.order_by { + push( + changes, + ChangeClass::OrderingChanged, + ChangeImpact::Breaking, + format!("{location}.orderBy"), + "the deterministic source ordering changed", + ); + } + diff_pagination( + previous.query.pagination.as_ref(), + current.query.pagination.as_ref(), + location, + changes, + ); + diff_request_bound( + previous.query.maximum_request_body_bytes, + current.query.maximum_request_body_bytes, + location, + changes, + ); + diff_access(&previous.access, ¤t.access, location, changes); +} + +fn classification_context( + value: &crate::model::EffectiveClassification, +) -> ( + &str, + &str, + &str, + &str, + &str, + &str, + crate::contract::ReviewStatus, + &str, +) { + ( + &value.privacy, + &value.privacy_scheme, + &value.privacy_version, + &value.institutional, + &value.institutional_scheme, + &value.institutional_version, + value.status, + &value.provenance_ref, + ) +} + +fn diff_pagination( + previous: Option<&crate::model::CompiledPagination>, + current: Option<&crate::model::CompiledPagination>, + location: &str, + changes: &mut Vec, +) { + match (previous, current) { + (Some(before), Some(after)) if before != after => { + let expanded = after.maximum_page_size > before.maximum_page_size + || after.default_page_size > before.default_page_size; + push( + changes, + if expanded { + ChangeClass::PaginationExpanded + } else { + ChangeClass::PaginationNarrowed + }, + if expanded { + ChangeImpact::Widening + } else { + ChangeImpact::Narrowing + }, + format!("{location}.pagination"), + "collection pagination bounds changed", + ); + } + (None, Some(_)) => push( + changes, + ChangeClass::PaginationExpanded, + ChangeImpact::Widening, + format!("{location}.pagination"), + "pagination was added", + ), + (Some(_), None) => push( + changes, + ChangeClass::PaginationNarrowed, + ChangeImpact::Breaking, + format!("{location}.pagination"), + "pagination was removed", + ), + _ => {} + } +} + +fn diff_request_bound( + previous: Option, + current: Option, + location: &str, + changes: &mut Vec, +) { + if previous == current { + return; + } + let expanded = match (previous, current) { + (Some(before), Some(after)) => after > before, + (None, Some(_)) => true, + (Some(_), None) => false, + (None, None) => return, + }; + push( + changes, + if expanded { + ChangeClass::RequestBoundExpanded + } else { + ChangeClass::RequestBoundNarrowed + }, + if expanded { + ChangeImpact::Widening + } else { + ChangeImpact::Narrowing + }, + format!("{location}.requestBody.maximumBytes"), + "lookup request-body bound changed", + ); +} + +fn diff_access( + previous: &CompiledAccess, + current: &CompiledAccess, + location: &str, + changes: &mut Vec, +) { + match (previous, current) { + (CompiledAccess::Protected { .. }, CompiledAccess::Public) => push( + changes, + ChangeClass::ScopeChanged, + ChangeImpact::Widening, + format!("{location}.access"), + "a protected operation became anonymous", + ), + (CompiledAccess::Public, CompiledAccess::Protected { .. }) => push( + changes, + ChangeClass::ScopeChanged, + ChangeImpact::Narrowing, + format!("{location}.access"), + "an anonymous operation became protected", + ), + ( + CompiledAccess::Protected { + scope: before_scope, + purpose: before_purpose, + row_binding: before_binding, + }, + CompiledAccess::Protected { + scope: after_scope, + purpose: after_purpose, + row_binding: after_binding, + }, + ) => { + if before_scope != after_scope { + push( + changes, + ChangeClass::ScopeChanged, + ChangeImpact::Widening, + format!("{location}.access.scope"), + "the registered operation scope changed and requires authorization review", + ); + } + if before_purpose + .as_ref() + .zip(after_purpose.as_ref()) + .is_some_and(|(before, after)| before.claim != after.claim) + { + push( + changes, + ChangeClass::PurposeExpanded, + ChangeImpact::Widening, + format!("{location}.access.purpose.claim"), + "the trusted purpose claim changed and requires authorization review", + ); + } + match (before_binding, after_binding) { + (Some(_), None) => push( + changes, + ChangeClass::RowBindingRemoved, + ChangeImpact::Widening, + format!("{location}.access.authorityRowBinding"), + "the principal-derived row boundary was removed", + ), + (None, Some(_)) => push( + changes, + ChangeClass::RowBindingAdded, + ChangeImpact::Narrowing, + format!("{location}.access.authorityRowBinding"), + "a principal-derived row boundary was added", + ), + (Some(before), Some(after)) if before != after => push( + changes, + ChangeClass::RowBindingChanged, + ChangeImpact::Widening, + format!("{location}.access.authorityRowBinding"), + "the principal-derived row boundary changed and requires review", + ), + _ => {} + } + let before_values = before_purpose + .as_ref() + .map(|purpose| purpose.allowed.iter().collect::>()) + .unwrap_or_default(); + let after_values = after_purpose + .as_ref() + .map(|purpose| purpose.allowed.iter().collect::>()) + .unwrap_or_default(); + if after_values.difference(&before_values).next().is_some() + || (before_purpose.is_some() && after_purpose.is_none()) + { + push( + changes, + ChangeClass::PurposeExpanded, + ChangeImpact::Widening, + format!("{location}.access.purpose"), + "the trusted purpose constraint expanded or was removed", + ); + } + if before_values.difference(&after_values).next().is_some() + || (before_purpose.is_none() && after_purpose.is_some()) + { + push( + changes, + ChangeClass::PurposeNarrowed, + ChangeImpact::Narrowing, + format!("{location}.access.purpose"), + "the trusted purpose constraint narrowed or was added", + ); + } + } + (CompiledAccess::Public, CompiledAccess::Public) => {} + } +} + +fn diff_visibility( + previous: &CompiledRegistry, + current: &CompiledRegistry, + changes: &mut Vec, +) { + let before = &previous.metadata_visibility; + let after = ¤t.metadata_visibility; + for (name, left, right) in [ + ("service", before.service, after.service), + ("resources", before.resources, after.resources), + ("semantics", before.semantics, after.semantics), + ( + "classifications", + before.classifications, + after.classifications, + ), + ("processing", before.processing, after.processing), + ] { + if left == right { + continue; + } + let relaxed = visibility_rank(right) < visibility_rank(left); + push( + changes, + if relaxed { + ChangeClass::MetadataVisibilityRelaxed + } else { + ChangeClass::MetadataVisibilityTightened + }, + if relaxed { + ChangeImpact::Widening + } else { + ChangeImpact::Narrowing + }, + format!("metadataVisibility.{name}"), + if relaxed { + "metadata became visible to a wider audience" + } else { + "metadata became visible to a narrower audience" + }, + ); + } +} + +fn visibility_rank(value: Visibility) -> u8 { + match value { + Visibility::Public => 0, + Visibility::OperationBound => 1, + Visibility::OperatorOnly => 2, + } +} + +fn push( + changes: &mut Vec, + class: ChangeClass, + impact: ChangeImpact, + location: String, + description: &str, +) { + changes.push(ContractChange { + class, + impact, + location, + description: description.into(), + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::compiler::{compile_contract_with_governed_files, tests as compiler_tests}; + use crate::contract::RegistryContract; + use crate::model::CompileProfile; + + fn compiled() -> CompiledRegistry { + let contract = RegistryContract::parse_yaml(compiler_tests::valid_contract()) + .expect("contract parses"); + compile_contract_with_governed_files( + &contract, + &[compiler_tests::observed_schema()], + CompileProfile::Production, + &compiler_tests::governed_files(), + ) + .expect("contract compiles") + } + + #[test] + fn visibility_order_is_security_monotonic() { + assert!(visibility_rank(Visibility::Public) < visibility_rank(Visibility::OperationBound)); + assert!( + visibility_rank(Visibility::OperationBound) < visibility_rank(Visibility::OperatorOnly) + ); + } + + #[test] + fn classification_and_processing_changes_are_reported() { + let previous = compiled(); + let mut current = previous.clone(); + current.resources[0].properties[0].classification.privacy = "sensitive".into(); + current.resources[0].processing_descriptions[0].purpose = "reviewed-purpose".into(); + + let report = diff_registries(&previous, ¤t); + assert!(report + .changes + .iter() + .any(|change| change.class == ChangeClass::ClassificationChanged)); + assert!(report + .changes + .iter() + .any(|change| change.class == ChangeClass::ProcessingChanged)); + } + + #[test] + fn governed_sidecar_digest_changes_are_reported() { + let previous = compiled(); + let mut current = previous.clone(); + current.governed_files[0].sha256 = format!("sha256:{}", "0".repeat(64)); + + let report = diff_registries(&previous, ¤t); + assert!(report + .changes + .iter() + .any(|change| change.class == ChangeClass::GovernedFileChanged)); + } + + #[test] + fn source_profile_or_schema_fingerprint_changes_are_reported() { + let previous = compiled(); + let mut current = previous.clone(); + current.sources[0].expected_schema_fingerprint = format!("sha256:{}", "1".repeat(64)); + + let report = diff_registries(&previous, ¤t); + assert!(report + .changes + .iter() + .any(|change| change.class == ChangeClass::SourceSchemaChanged)); + } + + #[test] + fn filter_unfiltered_and_query_shape_changes_are_reported() { + let mut previous = compiled(); + let operation = &mut previous.resources[0].operations[0]; + operation.query.filters.push(crate::model::CompiledFilter { + parameter: "name".into(), + property: "name".into(), + source_column: "name".into(), + data_type: crate::contract::DataType::String, + }); + operation.query.pagination = Some(crate::model::CompiledPagination { + default_page_size: 1, + maximum_page_size: 10, + }); + let mut current = previous.clone(); + let operation = &mut current.resources[0].operations[0]; + operation.query.filters[0].source_column = "replacement".into(); + operation.query.allow_unfiltered = !operation.query.allow_unfiltered; + operation.query.order_by.push("replacement".into()); + operation.query.pagination = Some(crate::model::CompiledPagination { + default_page_size: 2, + maximum_page_size: 20, + }); + + let report = diff_registries(&previous, ¤t); + for class in [ + ChangeClass::FilterChanged, + if previous.resources[0].operations[0].query.allow_unfiltered { + ChangeClass::UnfilteredDisabled + } else { + ChangeClass::UnfilteredEnabled + }, + ChangeClass::OrderingChanged, + ChangeClass::PaginationExpanded, + ] { + assert!( + report.changes.iter().any(|change| change.class == class), + "missing {class:?}" + ); + } + } + + #[test] + fn selector_and_request_bound_changes_are_reported() { + let mut previous = compiled(); + let operation = &mut previous.resources[0].operations[0]; + operation + .query + .selectors + .push(crate::model::CompiledSelector { + name: "name".into(), + source_column: "name".into(), + data_type: crate::contract::DataType::String, + minimum_bytes: Some(1), + maximum_bytes: Some(64), + codelist: None, + }); + operation.query.maximum_request_body_bytes = Some(512); + let mut current = previous.clone(); + let operation = &mut current.resources[0].operations[0]; + operation.query.selectors[0].maximum_bytes = Some(99); + operation.query.maximum_request_body_bytes = Some( + operation + .query + .maximum_request_body_bytes + .expect("request bound") + + 1, + ); + + let report = diff_registries(&previous, ¤t); + assert!(report + .changes + .iter() + .any(|change| change.class == ChangeClass::SelectorChanged)); + assert!(report + .changes + .iter() + .any(|change| change.class == ChangeClass::RequestBoundExpanded)); + } +} diff --git a/crates/registry-relay-v2/src/fixtures.rs b/crates/registry-relay-v2/src/fixtures.rs new file mode 100644 index 000000000..394b62c4a --- /dev/null +++ b/crates/registry-relay-v2/src/fixtures.rs @@ -0,0 +1,903 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Strict, value-free offline acceptance journeys over the real Relay router. + +use std::collections::{BTreeMap, BTreeSet}; + +use axum::body::{to_bytes, Body}; +use axum::http::header::{AUTHORIZATION, CACHE_CONTROL, CONTENT_TYPE, ETAG, VARY}; +use axum::http::{Request, StatusCode}; +use axum::Router; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine as _; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use thiserror::Error; +use tower::ServiceExt as _; + +use crate::auth::{FixturePrincipal, RelayAuthenticator}; +use crate::model::{CompiledAccess, CompiledRegistry, OperationKind}; + +const JOURNEY_VERSION: &str = "relay.registrystack.org/http-journey/v1alpha1"; +const MAXIMUM_RESPONSE_BYTES: usize = 8 * 1024 * 1024; + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct FixtureJourney { + pub schema_version: String, + pub registry: String, + #[serde(default)] + pub authorizations: BTreeMap, + pub steps: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct FixtureAuthorization { + pub principal: String, + pub scopes: BTreeSet, + #[serde(default)] + pub claims: BTreeMap, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct FixtureStep { + pub id: String, + #[serde(default)] + pub authorization_fixture: Option, + pub request: FixtureRequest, + pub expect: FixtureExpectation, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct FixtureRequest { + pub method: FixtureMethod, + pub path: String, + #[serde(default)] + pub headers: BTreeMap, + #[serde(default)] + pub query: BTreeMap, + #[serde(default)] + pub body: BTreeMap, +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "UPPERCASE")] +pub enum FixtureMethod { + Get, + Post, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct FixtureExpectation { + pub status: u16, + #[serde(default)] + pub code: Option, + #[serde(default)] + pub capability_patterns: Vec, + #[serde(default)] + pub absent_capability_patterns: Vec, + #[serde(default)] + pub item_count: Option, + #[serde(default)] + pub next_cursor: Option, + #[serde(default)] + pub registry_core_required: Option, + #[serde(default)] + pub domain_data_keys: Vec, + #[serde(default)] + pub record_identifier: Option, + #[serde(default)] + pub cache: Option, + #[serde(default)] + pub route_absent: Option, + #[serde(default)] + pub equivalence_class: Option, + #[serde(default)] + pub absent_everywhere: Vec, + #[serde(default)] + pub records_equivalent_to: Option, + #[serde(default)] + pub body_empty: Option, + #[serde(default)] + pub etag_same_as: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct FixturePlanReport { + pub registry_identifier: String, + pub selected_fixture: Option, + pub steps: Vec, + pub diagnostics: Vec, +} + +impl FixturePlanReport { + pub fn is_success(&self) -> bool { + self.diagnostics.is_empty() + } +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct FixturePlanStep { + pub id: String, + pub operation_identifier: Option, + pub expected_status: u16, + pub actual_status: Option, + pub actual_code: Option, + pub passed: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct FixtureDiagnostic { + pub code: String, + pub location: String, + pub message: String, +} + +#[derive(Debug, Error)] +pub enum FixtureError { + #[error("fixture YAML is not valid")] + InvalidYaml, +} + +pub fn parse_journey(yaml: &str) -> Result { + serde_norway::from_str(yaml).map_err(|_| FixtureError::InvalidYaml) +} + +/// Compile a journey against the exact runtime operation inventory without +/// opening the source. This is the structural preflight used before execution. +pub fn compile_fixture_plan( + registry: &CompiledRegistry, + journey: &FixtureJourney, + selected_fixture: Option<&str>, +) -> FixturePlanReport { + let mut diagnostics = Vec::new(); + if journey.schema_version != JOURNEY_VERSION { + diagnostic( + &mut diagnostics, + "fixture.schema_version_unsupported", + "schemaVersion", + "the fixture journey version is unsupported", + ); + } + if journey.registry != registry.registry_identifier { + diagnostic( + &mut diagnostics, + "fixture.registry_mismatch", + "registry", + "the fixture journey belongs to another Registry", + ); + } + validate_authorizations(journey, &mut diagnostics); + + let mut ids = BTreeSet::new(); + let mut steps = Vec::new(); + for (index, step) in journey.steps.iter().enumerate() { + if !ids.insert(step.id.as_str()) { + diagnostic( + &mut diagnostics, + "fixture.id_duplicate", + &format!("steps[{index}].id"), + "fixture step identifiers must be unique", + ); + } + if let Some(reference) = step.authorization_fixture.as_deref() { + if !journey.authorizations.contains_key(reference) { + diagnostic( + &mut diagnostics, + "fixture.authorization_unknown", + &format!("steps[{index}].authorizationFixture"), + "the authorization fixture identifier is unknown", + ); + } + } + if selected_fixture.is_some_and(|selected| selected != step.id) { + continue; + } + if !step.request.path.starts_with('/') || step.request.path.contains(['?', '#']) { + diagnostic( + &mut diagnostics, + "fixture.path_invalid", + &format!("steps[{index}].request.path"), + "fixture paths must be absolute URI paths without a query or fragment", + ); + } + if !matches!( + step.expect.status, + 200 | 304 | 400 | 401 | 403 | 404 | 406 | 413 | 414 | 415 | 429 | 500 | 503 | 504 + ) { + diagnostic( + &mut diagnostics, + "fixture.status_unsupported", + &format!("steps[{index}].expect.status"), + "the expected status is outside the Relay problem contract", + ); + } + let operation = resolve_operation(registry, &step.request); + if step.expect.status == 200 && is_data_path(&step.request.path) && operation.is_none() { + diagnostic( + &mut diagnostics, + "fixture.operation_unknown", + &format!("steps[{index}].request"), + "a successful fixture names no compiled operation", + ); + } + if let Some(operation) = operation { + if matches!(operation.access, CompiledAccess::Protected { .. }) + && step.expect.status == 200 + && step.authorization_fixture.is_none() + { + diagnostic( + &mut diagnostics, + "fixture.authorization_missing", + &format!("steps[{index}].authorizationFixture"), + "a protected successful fixture requires an authorization fixture", + ); + } + } + steps.push(FixturePlanStep { + id: step.id.clone(), + operation_identifier: operation.map(|operation| operation.identifier.clone()), + expected_status: step.expect.status, + actual_status: None, + actual_code: None, + passed: None, + }); + } + if let Some(selected) = selected_fixture { + if !journey.steps.iter().any(|step| step.id == selected) { + diagnostic( + &mut diagnostics, + "fixture.id_unknown", + "fixture", + "the selected fixture identifier is unknown", + ); + } + } + FixturePlanReport { + registry_identifier: registry.registry_identifier.clone(), + selected_fixture: selected_fixture.map(str::to_owned), + steps, + diagnostics, + } +} + +/// Execute each preflighted step against the real in-process Relay router. +/// Response bytes are inspected in memory and never copied into the report. +pub async fn execute_fixture_journey( + registry: &CompiledRegistry, + app: Router, + journey: &FixtureJourney, + selected_fixture: Option<&str>, +) -> FixturePlanReport { + let mut report = compile_fixture_plan(registry, journey, selected_fixture); + if !report.is_success() { + return report; + } + let mut equivalence_classes = BTreeMap::::new(); + let mut observations = BTreeMap::::new(); + for planned in &mut report.steps { + let Some((index, step)) = journey + .steps + .iter() + .enumerate() + .find(|(_, step)| step.id == planned.id) + else { + diagnostic( + &mut report.diagnostics, + "fixture.execution_failed", + "steps", + "the fixture step could not be executed", + ); + continue; + }; + let request = match fixture_request(step, &observations) { + Ok(request) => request, + Err(()) => { + diagnostic( + &mut report.diagnostics, + "fixture.request_invalid", + &format!("steps[{index}].request"), + "the fixture request could not be constructed", + ); + planned.passed = Some(false); + continue; + } + }; + let response = match app.clone().oneshot(request).await { + Ok(response) => response, + Err(error) => match error {}, + }; + let status = response.status(); + let headers = response.headers().clone(); + let body = match to_bytes(response.into_body(), MAXIMUM_RESPONSE_BYTES).await { + Ok(body) => body, + Err(_) => { + diagnostic( + &mut report.diagnostics, + "fixture.response_invalid", + &format!("steps[{index}].expect"), + "the fixture response exceeded the offline evaluation bound", + ); + planned.passed = Some(false); + continue; + } + }; + let document = serde_json::from_slice::(&body).ok(); + let actual_code = document + .as_ref() + .and_then(|value| value.get("code")) + .and_then(Value::as_str) + .map(str::to_owned); + planned.actual_status = Some(status.as_u16()); + planned.actual_code.clone_from(&actual_code); + planned.passed = Some(assert_expectations( + step, + &ObservedResponse { + status, + headers: &headers, + body: &body, + document: document.as_ref(), + code: actual_code.as_deref(), + }, + &mut equivalence_classes, + &observations, + index, + &mut report.diagnostics, + )); + observations.insert( + step.id.clone(), + FixtureObservation { + document, + etag: headers + .get(ETAG) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned), + }, + ); + } + report +} + +/// Build the only non-production authenticator used by `relayctl test`. +/// Its already-verified principals come solely from the strict journey input. +pub(crate) fn fixture_authenticator(journey: &FixtureJourney) -> Option { + (!journey.authorizations.is_empty()).then(|| { + RelayAuthenticator::for_offline_fixtures( + journey + .authorizations + .iter() + .map(|(identifier, fixture)| { + ( + fixture_token(identifier), + FixturePrincipal { + identifier: fixture.principal.clone(), + scopes: fixture.scopes.clone(), + claims: Value::Object( + fixture + .claims + .iter() + .map(|(name, value)| { + (name.clone(), Value::String(value.clone())) + }) + .collect(), + ), + }, + ) + }) + .collect(), + ) + }) +} + +fn fixture_request( + step: &FixtureStep, + observations: &BTreeMap, +) -> Result, ()> { + let mut url = step.request.path.clone(); + if !step.request.query.is_empty() { + let mut serializer = url::form_urlencoded::Serializer::new(String::new()); + for (name, value) in &step.request.query { + let value = query_value(value, observations).ok_or(())?; + serializer.append_pair(name, &value); + } + url.push('?'); + url.push_str(&serializer.finish()); + } + let method = match step.request.method { + FixtureMethod::Get => "GET", + FixtureMethod::Post => "POST", + }; + let body = if step.request.body.is_empty() { + Body::empty() + } else { + Body::from(serde_json::to_vec(&json!({"selectors": step.request.body})).map_err(|_| ())?) + }; + let mut request = Request::builder() + .method(method) + .uri(url) + .body(body) + .map_err(|_| ())?; + if !step.request.body.is_empty() { + request + .headers_mut() + .insert(CONTENT_TYPE, "application/json".parse().map_err(|_| ())?); + } + if let Some(identifier) = step.authorization_fixture.as_deref() { + request.headers_mut().insert( + AUTHORIZATION, + format!("Bearer {}", fixture_token(identifier)) + .parse() + .map_err(|_| ())?, + ); + } + for (name, value) in &step.request.headers { + if !matches!(name.as_str(), "accept" | "if-none-match") { + return Err(()); + } + let value = if let Some(reference) = value.strip_prefix("$etag:") { + observations + .get(reference) + .ok_or(())? + .etag + .as_deref() + .ok_or(())? + } else { + value + }; + request.headers_mut().insert( + http::header::HeaderName::from_bytes(name.as_bytes()).map_err(|_| ())?, + http::header::HeaderValue::from_str(value).map_err(|_| ())?, + ); + } + Ok(request) +} + +#[derive(Clone)] +struct FixtureObservation { + document: Option, + etag: Option, +} + +struct ObservedResponse<'a> { + status: StatusCode, + headers: &'a http::HeaderMap, + body: &'a [u8], + document: Option<&'a Value>, + code: Option<&'a str>, +} + +fn assert_expectations( + step: &FixtureStep, + response: &ObservedResponse<'_>, + equivalence_classes: &mut BTreeMap, + observations: &BTreeMap, + index: usize, + diagnostics: &mut Vec, +) -> bool { + let before = diagnostics.len(); + let location = format!("steps[{index}].expect"); + if response.status.as_u16() != step.expect.status { + mismatch(diagnostics, "fixture.status_mismatch", &location, "status"); + } + if step.expect.code.is_some() && step.expect.code.as_deref() != response.code { + mismatch( + diagnostics, + "fixture.code_mismatch", + &location, + "problem code", + ); + } + if step.expect.route_absent == Some(true) && response.code != Some("resource.not_found") { + mismatch( + diagnostics, + "fixture.route_mismatch", + &location, + "route posture", + ); + } + let records = response.document.map(response_records).unwrap_or_default(); + if let Some(expected) = step.expect.item_count { + let actual = response + .document + .and_then(|value| value.get("items")) + .and_then(Value::as_array) + .map(Vec::len); + if actual != usize::try_from(expected).ok() { + mismatch( + diagnostics, + "fixture.item_count_mismatch", + &location, + "record count", + ); + } + } + if let Some(expected) = step.expect.next_cursor.as_ref().and_then(Value::as_str) { + let cursor = response + .document + .and_then(|value| value.pointer("/pageInfo/nextCursor")); + let matches = match expected { + "non-null" => cursor.is_some_and(|value| !value.is_null()), + "null" => cursor.is_some_and(Value::is_null), + _ => false, + }; + if !matches { + mismatch( + diagnostics, + "fixture.cursor_mismatch", + &location, + "cursor posture", + ); + } + } + if step.expect.registry_core_required == Some(true) + && (records.is_empty() || records.iter().any(|record| !has_registry_core(record))) + { + mismatch( + diagnostics, + "fixture.registry_core_mismatch", + &location, + "Registry Core context", + ); + } + if !step.expect.domain_data_keys.is_empty() { + let expected = step + .expect + .domain_data_keys + .iter() + .map(String::as_str) + .collect::>(); + if records.is_empty() + || records.iter().any(|record| { + record + .get("domainData") + .and_then(Value::as_object) + .map(|object| object.keys().map(String::as_str).collect::>()) + .as_ref() + != Some(&expected) + }) + { + mismatch( + diagnostics, + "fixture.disclosure_mismatch", + &location, + "governed property set", + ); + } + } + if let Some(expected) = step.expect.record_identifier.as_deref() { + let actual = response + .document + .and_then(|value| value.pointer("/data/recordIdentifier")) + .and_then(Value::as_str); + if actual != Some(expected) { + mismatch( + diagnostics, + "fixture.record_mismatch", + &location, + "Record identity", + ); + } + } + assert_capabilities(step, response.document, &location, diagnostics); + if step.expect.cache.as_deref() == Some("public-snapshot-revalidation") + && (response + .headers + .get(CACHE_CONTROL) + .and_then(|value| value.to_str().ok()) + != Some("public, no-cache") + || !response.headers.contains_key(ETAG) + || response + .headers + .get(VARY) + .and_then(|value| value.to_str().ok()) + != Some("Accept, Authorization")) + { + mismatch( + diagnostics, + "fixture.cache_mismatch", + &location, + "cache posture", + ); + } + if step + .expect + .absent_everywhere + .iter() + .any(|value| contains_bytes(response.body, value.as_bytes())) + { + diagnostic( + diagnostics, + "fixture.disclosure_leak", + &location, + "the fixture disclosed a prohibited value or source binding", + ); + } + if let Some(class) = step.expect.equivalence_class.as_ref() { + let mut normalized = response.document.cloned().unwrap_or(Value::Null); + if let Some(object) = normalized.as_object_mut() { + object.remove("traceId"); + } + if equivalence_classes + .get(class) + .is_some_and(|previous| previous != &normalized) + { + mismatch( + diagnostics, + "fixture.equivalence_mismatch", + &location, + "outcome equivalence class", + ); + } else { + equivalence_classes.insert(class.clone(), normalized); + } + } + if let Some(reference) = step.expect.records_equivalent_to.as_ref() { + let previous = observations + .get(reference) + .and_then(|observation| observation.document.as_ref()) + .map(normalized_records); + let current = response.document.map(normalized_records); + if previous.is_none() || current != previous { + mismatch( + diagnostics, + "fixture.representation_mismatch", + &location, + "JSON and JSON-LD Record equivalence", + ); + } + } + if step.expect.body_empty == Some(true) && !response.body.is_empty() { + mismatch( + diagnostics, + "fixture.body_mismatch", + &location, + "empty response body", + ); + } + if let Some(reference) = step.expect.etag_same_as.as_ref() { + let previous = observations + .get(reference) + .and_then(|observation| observation.etag.as_deref()); + let current = response + .headers + .get(ETAG) + .and_then(|value| value.to_str().ok()); + if previous.is_none() || current != previous { + mismatch( + diagnostics, + "fixture.etag_mismatch", + &location, + "revalidation entity tag", + ); + } + } + before == diagnostics.len() +} + +fn assert_capabilities( + step: &FixtureStep, + document: Option<&Value>, + location: &str, + diagnostics: &mut Vec, +) { + if step.expect.capability_patterns.is_empty() + && step.expect.absent_capability_patterns.is_empty() + { + return; + } + let patterns = document + .and_then(|value| value.get("capabilities")) + .and_then(Value::as_array) + .map(|capabilities| { + capabilities + .iter() + .filter_map(|capability| { + Some(format!( + "{}.{}", + capability.get("family")?.as_str()?, + capability.get("pattern")?.as_str()? + )) + }) + .collect::>() + }) + .unwrap_or_default(); + if step + .expect + .capability_patterns + .iter() + .any(|expected| !patterns.contains(expected)) + || step + .expect + .absent_capability_patterns + .iter() + .any(|absent| patterns.contains(absent)) + { + mismatch( + diagnostics, + "fixture.capability_mismatch", + location, + "capability inventory", + ); + } +} + +fn validate_authorizations(journey: &FixtureJourney, diagnostics: &mut Vec) { + for (index, fixture) in journey.authorizations.values().enumerate() { + if fixture.principal.is_empty() + || fixture.scopes.is_empty() + || fixture.scopes.iter().any(String::is_empty) + || fixture + .claims + .iter() + .any(|(name, value)| name.is_empty() || value.is_empty()) + { + diagnostic( + diagnostics, + "fixture.authorization_invalid", + &format!("authorizations[{index}]"), + "authorization fixtures require a principal, scopes, and direct string claims", + ); + } + } +} + +fn resolve_operation<'a>( + registry: &'a CompiledRegistry, + request: &FixtureRequest, +) -> Option<&'a crate::model::CompiledOperation> { + registry.resources.iter().find_map(|resource| { + resource + .operations + .iter() + .find(|operation| match &operation.kind { + OperationKind::List => { + request.method == FixtureMethod::Get + && request.path == format!("/v2/resources/{}/records", resource.id) + } + OperationKind::Read => { + request.method == FixtureMethod::Get + && request + .path + .starts_with(&format!("/v2/resources/{}/records/", resource.id)) + } + OperationKind::Lookup { name } => { + request.method == FixtureMethod::Post + && request.path == format!("/v2/resources/{}/lookups/{name}", resource.id) + } + }) + }) +} + +fn query_value( + value: &Value, + observations: &BTreeMap, +) -> Option { + match value { + Value::String(value) => value + .strip_prefix("$nextCursor:") + .map(|reference| { + observations + .get(reference)? + .document + .as_ref()? + .pointer("/pageInfo/nextCursor")? + .as_str() + .map(str::to_owned) + }) + .unwrap_or_else(|| Some(value.clone())), + Value::Bool(value) => Some(value.to_string()), + Value::Number(value) => Some(value.to_string()), + Value::Null | Value::Array(_) | Value::Object(_) => None, + } +} + +fn normalized_records(document: &Value) -> Value { + let mut records = response_records(document) + .into_iter() + .cloned() + .collect::>(); + for record in &mut records { + if let Some(object) = record.as_object_mut() { + object.remove("@context"); + object.remove("@id"); + } + } + Value::Array(records) +} + +fn fixture_token(identifier: &str) -> String { + let header = URL_SAFE_NO_PAD.encode(br#"{"alg":"none","typ":"JWT"}"#); + let claims = URL_SAFE_NO_PAD.encode( + serde_json::to_vec(&json!({"fixture": identifier})) + .expect("fixture token claims are canonical JSON"), + ); + let signature = URL_SAFE_NO_PAD.encode(b"offline-fixture"); + format!("{header}.{claims}.{signature}") +} + +fn response_records(document: &Value) -> Vec<&Value> { + if let Some(record) = document.get("data") { + vec![record] + } else { + document + .get("items") + .and_then(Value::as_array) + .map_or_else(Vec::new, |items| items.iter().collect()) + } +} + +fn has_registry_core(record: &Value) -> bool { + [ + "registryIdentifier", + "recordIdentifier", + "revisionIdentifier", + "lifecycleState", + "schemaReference", + "semanticModelReference", + "authorityIdentifier", + "recordedAt", + "domainData", + ] + .iter() + .all(|key| record.get(key).is_some()) +} + +fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool { + !needle.is_empty() + && haystack + .windows(needle.len()) + .any(|window| window == needle) +} + +fn is_data_path(path: &str) -> bool { + path.contains("/records") || path.contains("/lookups/") +} + +fn mismatch(diagnostics: &mut Vec, code: &str, location: &str, subject: &str) { + diagnostic( + diagnostics, + code, + location, + &format!("the fixture returned a different {subject}"), + ); +} + +fn diagnostic(diagnostics: &mut Vec, code: &str, location: &str, message: &str) { + diagnostics.push(FixtureDiagnostic { + code: code.into(), + location: location.into(), + message: message.into(), + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fixture_yaml_rejects_unknown_request_fields() { + let yaml = r#" +schemaVersion: relay.registrystack.org/http-journey/v1alpha1 +registry: urn:example:registry +authorizations: {} +steps: + - id: one + request: {method: GET, path: /health, sql: SELECT 1} + expect: {status: 200} +"#; + assert!(parse_journey(yaml).is_err()); + } + + #[test] + fn fixture_tokens_have_a_bounded_jwt_shape_without_exposing_claims() { + let token = fixture_token("fixture-a"); + assert_eq!(token.split('.').count(), 3); + assert!(!token.contains("principal")); + } +} diff --git a/crates/registry-relay-v2/src/lib.rs b/crates/registry-relay-v2/src/lib.rs new file mode 100644 index 000000000..b97aa8442 --- /dev/null +++ b/crates/registry-relay-v2/src/lib.rs @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Relay V2's shared governed-contract compiler and runtime kernel. + +pub mod api; +pub mod artifacts; +pub mod audit; +pub mod auth; +pub mod compiler; +pub mod contract; +pub mod cursor; +pub mod diff; +#[cfg(feature = "tooling")] +pub mod fixtures; +pub mod model; +pub mod package; +pub mod problem; +pub mod semantics; +pub mod server; +mod source_observation; +pub mod sqlite_runtime; +pub mod startup; +#[cfg(feature = "tooling")] +pub mod tooling; + +pub use compiler::{compile, CompileError}; +pub use contract::{RegistryContract, RelayRuntime}; +pub use model::{CompileProfile, CompiledRegistry, ObservedSourceSchema}; diff --git a/crates/registry-relay-v2/src/main.rs b/crates/registry-relay-v2/src/main.rs new file mode 100644 index 000000000..cc08fc6e6 --- /dev/null +++ b/crates/registry-relay-v2/src/main.rs @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: Apache-2.0 +//! The Relay V2 `relay` process. + +use std::path::PathBuf; +use std::process::ExitCode; + +use clap::{Parser, Subcommand}; + +#[derive(Debug, Parser)] +#[command( + name = "relay", + about = "Compiled read-only Registry Relay runtime", + version = registry_platform_buildinfo::DISPLAY_VERSION +)] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Debug, Subcommand)] +enum Command { + /// Verify and activate one sealed Registry package, then serve it. + Serve { + /// Strict deployment binding for the sealed package and local resources. + #[arg(long, env = "RELAY_RUNTIME")] + runtime: PathBuf, + }, + /// Probe an unauthenticated Relay liveness endpoint. + Healthcheck { + /// Complete HTTP(S) URL of the Relay `/health` endpoint. + #[arg(long)] + url: String, + }, +} + +#[tokio::main] +async fn main() -> ExitCode { + install_operational_logging(); + let result = match Cli::parse().command { + Command::Serve { runtime } => registry_relay_v2::startup::serve(&runtime).await, + Command::Healthcheck { url } => registry_relay_v2::startup::healthcheck(&url).await, + }; + match result { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + tracing::error!(target: "registry_relay_v2", error = %error, "relay command failed"); + ExitCode::FAILURE + } + } +} + +/// Install bounded structured operational logs on stderr. Relay-owned events +/// deliberately carry only fixed messages and value-free dimensions. +fn install_operational_logging() { + let configured = std::env::var("RELAY_LOG").ok(); + let filter = + tracing_subscriber::EnvFilter::new(operational_log_directive(configured.as_deref())); + tracing_subscriber::fmt() + .json() + .with_env_filter(filter) + .with_current_span(true) + .with_span_list(false) + .with_writer(std::io::stderr) + .init(); +} + +/// Accept only one closed level for Relay-owned targets. An arbitrary tracing +/// directive could enable dependency events containing URLs or headers. +fn operational_log_directive(configured: Option<&str>) -> &'static str { + match configured { + Some("off") => "registry_relay_v2=off", + Some("error") => "registry_relay_v2=error", + Some("warn") => "registry_relay_v2=warn", + Some("debug") => "registry_relay_v2=debug", + Some("trace") => "registry_relay_v2=trace", + Some("info") | None => "registry_relay_v2=info", + Some(_) => "registry_relay_v2=info", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn operational_log_filter_cannot_enable_dependency_targets() { + assert_eq!( + operational_log_directive(Some("trace,hyper=trace")), + "registry_relay_v2=info" + ); + assert_eq!( + operational_log_directive(Some("registry_relay_v2=off,reqwest=trace")), + "registry_relay_v2=info" + ); + assert_eq!( + operational_log_directive(Some("debug")), + "registry_relay_v2=debug" + ); + } +} diff --git a/crates/registry-relay-v2/src/model.rs b/crates/registry-relay-v2/src/model.rs new file mode 100644 index 000000000..6ce456103 --- /dev/null +++ b/crates/registry-relay-v2/src/model.rs @@ -0,0 +1,354 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Immutable compiled model and rendering-neutral reports. + +use serde::{Deserialize, Serialize}; + +use crate::contract::{ + AlignmentTarget, DataType, Handling, ProcessingDescription, SemanticAlignment, SourceProfile, + Visibility, +}; + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Diagnostic { + pub severity: DiagnosticSeverity, + pub code: String, + pub location: String, + pub message: String, +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum DiagnosticSeverity { + Error, + Warning, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CompileReport { + pub diagnostics: Vec, +} + +impl CompileReport { + pub fn has_errors(&self) -> bool { + self.diagnostics + .iter() + .any(|item| item.severity == DiagnosticSeverity::Error) + } +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum CompileProfile { + Authoring, + Production, +} + +/// Product-neutral result of inspecting every reviewed source view. The +/// SQLite platform crate owns extraction and fingerprint calculation. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ObservedSourceSchema { + pub source: String, + pub fingerprint: String, + pub views: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ObservedView { + pub name: String, + pub columns: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ObservedColumn { + pub name: String, + pub declared_type: String, + pub nullable: bool, + pub primary_key: bool, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CompiledRegistry { + pub contract_revision: String, + pub contract_id: String, + pub contract_version: String, + pub registry_identifier: String, + pub registry_name: String, + pub authority_identifier: String, + pub operator_identifier: Option, + pub authoritative_scope: String, + pub base_uri: String, + pub identifier_lifecycle_policy_ref: String, + pub alignment_targets: Vec, + pub controller_identifier: String, + pub publisher_identifier: String, + pub audit_owner_identifier: String, + pub local_vocabulary: String, + pub semantic_alignments: Vec, + pub governed_files: Vec, + pub codelists: Vec, + pub sources: Vec, + pub resources: Vec, + pub metadata_visibility: CompiledMetadataVisibility, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CompiledGovernedFile { + pub path: String, + pub sha256: String, + pub roles: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CompiledCodelist { + pub path: String, + pub id: String, + pub version: String, + pub values: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CompiledSource { + pub id: String, + pub profile: SourceProfile, + pub expected_schema_fingerprint: String, + pub observed_schema: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CompiledResource { + pub id: String, + pub title: String, + pub description: String, + pub semantic_class: String, + pub source: String, + pub view: String, + pub record_context: CompiledRecordContext, + pub properties: Vec, + pub disclosure_profiles: Vec, + pub operations: Vec, + pub column_accounting: Vec, + pub processing_descriptions: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CompiledRecordContext { + pub record_identifier_column: String, + pub revision_identifier_column: String, + pub lifecycle_state_column: String, + pub lifecycle_state_codelist: String, + pub recorded_at_column: String, + pub schema_reference: String, + pub semantic_model_reference: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CompiledProperty { + pub name: String, + pub label: String, + pub description: String, + pub source_column: String, + pub data_type: DataType, + pub codelist: Option, + pub source_required: bool, + pub semantic_iri: String, + pub classification: EffectiveClassification, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct EffectiveClassification { + pub privacy: String, + pub privacy_scheme: String, + pub privacy_version: String, + pub institutional: String, + pub institutional_scheme: String, + pub institutional_version: String, + pub handling: Handling, + pub handling_scheme: String, + pub handling_version: String, + pub status: crate::contract::ReviewStatus, + pub provenance_ref: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CompiledDisclosureProfile { + pub id: String, + /// Maximum and default set in authored order. + pub properties: Vec, + pub maximum_handling: Handling, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CompiledOperation { + pub identifier: String, + pub family: CapabilityFamily, + pub pattern: ConsultationPattern, + pub kind: OperationKind, + pub access: CompiledAccess, + pub disclosure_profile: String, + pub selectable_properties: Vec, + pub query: QueryPlan, + pub maximum_handling: Handling, + pub schema_reference: String, + pub semantic_model_reference: String, + pub context_reference: String, +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum CapabilityFamily { + Consultation, +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum ConsultationPattern { + List, + Retrieve, + Search, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum OperationKind { + List, + Read, + Lookup { name: String }, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "kebab-case")] +pub enum CompiledAccess { + Public, + Protected { + scope: String, + purpose: Option, + row_binding: Option, + }, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CompiledPurpose { + pub claim: String, + pub allowed: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CompiledRowBinding { + pub source: RowAuthoritySource, + pub source_column: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(tag = "kind", content = "claim", rename_all = "kebab-case")] +pub enum RowAuthoritySource { + Principal, + Claim(String), +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct QueryPlan { + pub source: String, + pub view: String, + pub projected_columns: Vec, + pub filters: Vec, + pub selectors: Vec, + pub order_by: Vec, + pub allow_unfiltered: bool, + pub pagination: Option, + pub maximum_request_body_bytes: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CompiledFilter { + pub parameter: String, + pub property: String, + pub source_column: String, + pub data_type: DataType, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CompiledSelector { + pub name: String, + pub source_column: String, + pub data_type: DataType, + pub minimum_bytes: Option, + pub maximum_bytes: Option, + pub codelist: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CompiledPagination { + pub default_page_size: u32, + pub maximum_page_size: u32, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ColumnAccount { + pub column: String, + pub uses: Vec, + pub classification: EffectiveClassification, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "kebab-case")] +pub enum ColumnUse { + RecordIdentifier, + RevisionIdentifier, + LifecycleState, + RecordedAt, + Property(String), + Filter(String), + Order, + Selector(String), + RowBinding(String), +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CompiledMetadataVisibility { + pub service: Visibility, + pub resources: Visibility, + pub semantics: Visibility, + pub classifications: Visibility, + pub processing: Visibility, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct StarterContract { + pub source: String, + pub view: String, + pub expected_schema_fingerprint: String, + pub columns: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct StarterColumn { + pub source_column: String, + pub suggested_property: String, + pub suggested_type: DataType, + pub classification_status: crate::contract::ReviewStatus, +} diff --git a/crates/registry-relay-v2/src/package.rs b/crates/registry-relay-v2/src/package.rs new file mode 100644 index 000000000..7a47764b2 --- /dev/null +++ b/crates/registry-relay-v2/src/package.rs @@ -0,0 +1,851 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Deterministic sealed package construction from one compiled Registry. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Component, Path}; + +use registry_platform_canonical_json::canonicalize_json; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use thiserror::Error; + +use crate::artifacts::{generate_artifacts, ArtifactSet, GeneratedArtifact}; +use crate::compiler::{compile_contract_with_governed_files, GovernedFileSet}; +use crate::contract::{RegistryContract, Visibility}; +use crate::model::{CompileProfile, CompiledRegistry, ObservedSourceSchema}; + +const PACKAGE_VERSION: &str = "relay.registrystack.org/package/v1alpha1"; +const MAX_AUTHORED_FILES: usize = 256; +const MAX_AUTHORED_BYTES: u64 = 16 * 1024 * 1024; +const MAX_PACKAGE_FILES: usize = 1_024; +const MAX_PACKAGE_BYTES: u64 = 64 * 1024 * 1024; +const MAX_MANIFEST_BYTES: u64 = 4 * 1024 * 1024; + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct PackageManifest { + pub package_version: String, + pub package_revision: String, + pub contract_revision: String, + pub source_schema_fingerprints: BTreeMap, + pub source_schemas: BTreeMap, + pub artifacts: Vec, + pub files: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct PackageArtifact { + pub id: String, + pub path: String, + pub media_type: String, + pub visibility: Visibility, + pub operation_identifier: Option, + pub sha256: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct PackageFile { + pub path: String, + pub size: u64, + pub sha256: String, + pub media_type: String, + pub visibility: Visibility, + pub generated: bool, +} + +#[derive(Debug, Error)] +pub enum PackageError { + #[error("the project closure is unsafe")] + UnsafeClosure, + #[error("the project closure exceeds package bounds")] + ClosureBound, + #[error("the package destination is not empty")] + DestinationExists, + #[error("a package file could not be read")] + Read, + #[error("the sealed package could not be written")] + Write, + #[error("the package manifest could not be canonicalized")] + CanonicalJson, + #[error("the sealed package failed verification")] + Verification, +} + +#[derive(Clone, Debug)] +pub struct VerifiedPackage { + pub manifest: PackageManifest, + pub contract: RegistryContract, + pub registry: CompiledRegistry, + pub artifacts: ArtifactSet, +} + +/// Construct a new package directory. Existing destinations are refused so a +/// failed run can never leave a mixture of package revisions. +pub fn build_package( + project_root: &Path, + output_dir: &Path, + contract: &RegistryContract, + compiled: &CompiledRegistry, + artifacts: &ArtifactSet, +) -> Result { + if output_dir.exists() { + return Err(PackageError::DestinationExists); + } + let authored = capture_governed_closure(project_root, contract)?; + let mut files = Vec::new(); + let registry_bytes = read_regular(&project_root.join("registry.yaml"))?; + files.push(file_entry( + "registry.yaml", + ®istry_bytes, + "application/yaml", + Visibility::OperatorOnly, + false, + )); + for (relative, content) in &authored { + files.push(file_entry( + &format!("governed/{relative}"), + content, + media_type(relative), + Visibility::OperatorOnly, + false, + )); + } + for artifact in &artifacts.artifacts { + files.push(file_entry( + &format!("generated/{}", artifact.path), + &artifact.content, + &artifact.media_type, + artifact.visibility, + true, + )); + } + files.sort_by(|left, right| left.path.cmp(&right.path)); + + let packaged_artifacts = artifacts + .artifacts + .iter() + .map(|artifact| PackageArtifact { + id: artifact.id.clone(), + path: format!("generated/{}", artifact.path), + media_type: artifact.media_type.clone(), + visibility: artifact.visibility, + operation_identifier: artifact.operation_identifier.clone(), + sha256: artifact.sha256.clone(), + }) + .collect::>(); + let source_schema_fingerprints = compiled + .sources + .iter() + .map(|source| { + ( + source.id.clone(), + source.expected_schema_fingerprint.clone(), + ) + }) + .collect(); + let source_schemas = compiled + .sources + .iter() + .map(|source| { + source + .observed_schema + .clone() + .map(|schema| (source.id.clone(), schema)) + .ok_or(PackageError::Verification) + }) + .collect::, _>>()?; + let unsigned = UnsignedManifest { + package_version: PACKAGE_VERSION, + contract_revision: &compiled.contract_revision, + source_schema_fingerprints: &source_schema_fingerprints, + source_schemas: &source_schemas, + artifacts: &packaged_artifacts, + files: &files, + }; + let manifest_value = serde_json::to_value(unsigned).map_err(|_| PackageError::CanonicalJson)?; + let manifest_bytes = + canonicalize_json(&manifest_value).map_err(|_| PackageError::CanonicalJson)?; + let package_revision = digest(&manifest_bytes); + let manifest = PackageManifest { + package_version: PACKAGE_VERSION.into(), + package_revision, + contract_revision: compiled.contract_revision.clone(), + source_schema_fingerprints, + source_schemas, + artifacts: packaged_artifacts, + files, + }; + let final_manifest = canonicalize_json( + &serde_json::to_value(&manifest).map_err(|_| PackageError::CanonicalJson)?, + ) + .map_err(|_| PackageError::CanonicalJson)?; + + fs::create_dir(output_dir).map_err(|_| PackageError::Write)?; + let write_result = (|| { + write_new_file(&output_dir.join("registry.yaml"), ®istry_bytes)?; + for (relative, content) in &authored { + write_new_file(&output_dir.join("governed").join(relative), content)?; + } + for artifact in &artifacts.artifacts { + write_generated(output_dir, artifact)?; + } + write_new_file(&output_dir.join("relay-package.json"), &final_manifest) + })(); + if write_result.is_err() { + // Do not remove a partially written directory here. A caller can + // inspect it, and a subsequent package attempt will refuse it rather + // than silently overwriting evidence. + return Err(PackageError::Write); + } + harden_package_permissions(output_dir)?; + Ok(manifest) +} + +/// Load and verify a sealed package before any listener, issuer, audit sink, +/// or SQLite source is activated. +pub fn load_package(package_path: &Path) -> Result { + reject_symlink_path(package_path)?; + let package_metadata = fs::symlink_metadata(package_path).map_err(|_| PackageError::Read)?; + if !package_metadata.is_dir() || !safe_permissions(&package_metadata) { + return Err(PackageError::Verification); + } + let manifest_path = package_path.join("relay-package.json"); + let manifest_metadata = fs::symlink_metadata(&manifest_path).map_err(|_| PackageError::Read)?; + if !manifest_metadata.is_file() + || manifest_metadata.len() > MAX_MANIFEST_BYTES + || !safe_permissions(&manifest_metadata) + { + return Err(PackageError::Verification); + } + let manifest_bytes = read_regular(&manifest_path)?; + let manifest: PackageManifest = + serde_json::from_slice(&manifest_bytes).map_err(|_| PackageError::Verification)?; + if manifest.package_version != PACKAGE_VERSION + || manifest.files.is_empty() + || manifest.files.len() > MAX_PACKAGE_FILES + { + return Err(PackageError::Verification); + } + let canonical_manifest = canonicalize_json( + &serde_json::to_value(&manifest).map_err(|_| PackageError::CanonicalJson)?, + ) + .map_err(|_| PackageError::CanonicalJson)?; + if canonical_manifest != manifest_bytes { + return Err(PackageError::Verification); + } + let unsigned = UnsignedManifest { + package_version: PACKAGE_VERSION, + contract_revision: &manifest.contract_revision, + source_schema_fingerprints: &manifest.source_schema_fingerprints, + source_schemas: &manifest.source_schemas, + artifacts: &manifest.artifacts, + files: &manifest.files, + }; + let unsigned_bytes = canonicalize_json( + &serde_json::to_value(unsigned).map_err(|_| PackageError::CanonicalJson)?, + ) + .map_err(|_| PackageError::CanonicalJson)?; + if digest(&unsigned_bytes) != manifest.package_revision { + return Err(PackageError::Verification); + } + + let mut listed = BTreeSet::new(); + let mut loaded = BTreeMap::new(); + let mut total = manifest_bytes.len() as u64; + for entry in &manifest.files { + validate_relative(&entry.path)?; + if !listed.insert(entry.path.as_str()) { + return Err(PackageError::Verification); + } + reject_relative_symlinks(package_path, Path::new(&entry.path))?; + let path = package_path.join(&entry.path); + let metadata = fs::symlink_metadata(&path).map_err(|_| PackageError::Read)?; + if !metadata.is_file() + || metadata.file_type().is_symlink() + || !safe_permissions(&metadata) + || metadata.len() != entry.size + { + return Err(PackageError::Verification); + } + total = total + .checked_add(metadata.len()) + .ok_or(PackageError::ClosureBound)?; + if total > MAX_PACKAGE_BYTES { + return Err(PackageError::ClosureBound); + } + let content = read_regular(&path)?; + if digest(&content) != entry.sha256 { + return Err(PackageError::Verification); + } + loaded.insert(entry.path.clone(), content); + } + let actual = enumerate_package_files(package_path)?; + let mut expected = listed + .iter() + .map(|path| (*path).to_owned()) + .collect::>(); + expected.insert("relay-package.json".into()); + if actual != expected { + return Err(PackageError::Verification); + } + + let registry_entry = manifest + .files + .iter() + .filter(|entry| entry.path == "registry.yaml") + .collect::>(); + if registry_entry.len() != 1 + || registry_entry[0].generated + || registry_entry[0].visibility != Visibility::OperatorOnly + { + return Err(PackageError::Verification); + } + let contract_bytes = loaded + .get("registry.yaml") + .ok_or(PackageError::Verification)?; + let contract_text = + std::str::from_utf8(contract_bytes).map_err(|_| PackageError::Verification)?; + let contract = + RegistryContract::parse_yaml(contract_text).map_err(|_| PackageError::Verification)?; + let governed = loaded + .iter() + .filter_map(|(path, content)| { + path.strip_prefix("governed/") + .map(|relative| (relative.to_owned(), content.clone())) + }) + .collect::(); + if manifest.source_schemas.keys().collect::>() + != manifest + .source_schema_fingerprints + .keys() + .collect::>() + || manifest.source_schemas.iter().any(|(id, schema)| { + schema.source != *id || schema.fingerprint != manifest.source_schema_fingerprints[id] + }) + { + return Err(PackageError::Verification); + } + let observed = manifest + .source_schemas + .values() + .cloned() + .collect::>(); + let registry = compile_contract_with_governed_files( + &contract, + &observed, + CompileProfile::Production, + &governed, + ) + .map_err(|_| PackageError::Verification)?; + if registry.contract_revision != manifest.contract_revision + || registry + .sources + .iter() + .map(|source| { + ( + source.id.clone(), + source.expected_schema_fingerprint.clone(), + ) + }) + .collect::>() + != manifest.source_schema_fingerprints + { + return Err(PackageError::Verification); + } + + let regenerated = generate_artifacts(®istry).map_err(|_| PackageError::Verification)?; + let expected_artifacts = regenerated + .artifacts + .iter() + .map(|artifact| PackageArtifact { + id: artifact.id.clone(), + path: format!("generated/{}", artifact.path), + media_type: artifact.media_type.clone(), + visibility: artifact.visibility, + operation_identifier: artifact.operation_identifier.clone(), + sha256: artifact.sha256.clone(), + }) + .collect::>(); + if expected_artifacts != manifest.artifacts { + return Err(PackageError::Verification); + } + let mut artifacts = regenerated; + for artifact in &mut artifacts.artifacts { + let packaged_path = format!("generated/{}", artifact.path); + let packaged = loaded + .get(&packaged_path) + .ok_or(PackageError::Verification)?; + if packaged != &artifact.content { + return Err(PackageError::Verification); + } + // Retain bytes read from the sealed package after reproducing them. + artifact.content.clone_from(packaged); + } + Ok(VerifiedPackage { + manifest, + contract, + registry, + artifacts, + }) +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct UnsignedManifest<'a> { + package_version: &'static str, + contract_revision: &'a str, + source_schema_fingerprints: &'a BTreeMap, + source_schemas: &'a BTreeMap, + artifacts: &'a [PackageArtifact], + files: &'a [PackageFile], +} + +fn capture_governed_closure( + project_root: &Path, + contract: &RegistryContract, +) -> Result>, PackageError> { + let mut references = BTreeSet::new(); + references.insert(contract.registry.identifier_lifecycle_policy_ref.as_str()); + references.insert(contract.classifications.provenance_ref.as_str()); + for alignment in &contract.semantics.alignments { + references.insert(alignment.profile_ref.as_str()); + } + for resource in &contract.resources { + references.insert(resource.record_context.lifecycle_state.codelist.as_str()); + for (_, property) in resource.properties.iter() { + if let Some(codelist) = property.codelist.as_deref() { + references.insert(codelist); + } + } + for processing in &resource.processing_descriptions { + references.insert(processing.legal_basis_ref.as_str()); + references.insert(processing.dpv_profile_ref.as_str()); + } + } + if references.len() > MAX_AUTHORED_FILES { + return Err(PackageError::ClosureBound); + } + let root = project_root + .canonicalize() + .map_err(|_| PackageError::Read)?; + let mut captured = BTreeMap::new(); + let mut total = 0_u64; + for reference in references { + validate_relative(reference)?; + reject_relative_symlinks(&root, Path::new(reference))?; + let path = root.join(reference); + let metadata = fs::symlink_metadata(&path).map_err(|_| PackageError::Read)?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(PackageError::UnsafeClosure); + } + let canonical = path.canonicalize().map_err(|_| PackageError::Read)?; + if !canonical.starts_with(&root) { + return Err(PackageError::UnsafeClosure); + } + total = total + .checked_add(metadata.len()) + .ok_or(PackageError::ClosureBound)?; + if total > MAX_AUTHORED_BYTES { + return Err(PackageError::ClosureBound); + } + captured.insert(reference.to_owned(), read_regular(&canonical)?); + } + Ok(captured) +} + +fn validate_relative(value: &str) -> Result<(), PackageError> { + let path = Path::new(value); + if path.as_os_str().is_empty() + || path.is_absolute() + || path.components().any(|component| { + matches!( + component, + Component::ParentDir | Component::RootDir | Component::Prefix(_) + ) + }) + { + return Err(PackageError::UnsafeClosure); + } + Ok(()) +} + +fn read_regular(path: &Path) -> Result, PackageError> { + let metadata = fs::symlink_metadata(path).map_err(|_| PackageError::Read)?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(PackageError::UnsafeClosure); + } + fs::read(path).map_err(|_| PackageError::Read) +} + +fn reject_symlink_path(path: &Path) -> Result<(), PackageError> { + let absolute = if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir() + .map_err(|_| PackageError::Read)? + .join(path) + }; + let effective_user = current_effective_user(); + let component_count = absolute.components().count(); + let mut current = std::path::PathBuf::new(); + for (index, component) in absolute.components().enumerate() { + current.push(component.as_os_str()); + match fs::symlink_metadata(¤t) { + Ok(metadata) + if metadata.file_type().is_symlink() + || !metadata.is_dir() + || !safe_ancestor_permissions(&metadata, effective_user) + || index + 1 == component_count && !safe_permissions(&metadata) => + { + return Err(PackageError::UnsafeClosure); + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Err(PackageError::Read); + } + Err(_) => return Err(PackageError::Read), + } + } + Ok(()) +} + +fn reject_relative_symlinks(root: &Path, relative: &Path) -> Result<(), PackageError> { + let mut current = root.to_path_buf(); + for component in relative.components() { + let Component::Normal(component) = component else { + return Err(PackageError::UnsafeClosure); + }; + current.push(component); + let metadata = fs::symlink_metadata(¤t).map_err(|_| PackageError::Read)?; + if metadata.file_type().is_symlink() { + return Err(PackageError::UnsafeClosure); + } + } + Ok(()) +} + +fn enumerate_package_files(root: &Path) -> Result, PackageError> { + fn visit( + root: &Path, + directory: &Path, + files: &mut BTreeSet, + ) -> Result<(), PackageError> { + let metadata = fs::symlink_metadata(directory).map_err(|_| PackageError::Read)?; + if metadata.file_type().is_symlink() || !metadata.is_dir() || !safe_permissions(&metadata) { + return Err(PackageError::Verification); + } + for entry in fs::read_dir(directory).map_err(|_| PackageError::Read)? { + let entry = entry.map_err(|_| PackageError::Read)?; + let path = entry.path(); + let metadata = fs::symlink_metadata(&path).map_err(|_| PackageError::Read)?; + if metadata.file_type().is_symlink() || !safe_permissions(&metadata) { + return Err(PackageError::Verification); + } + if metadata.is_dir() { + visit(root, &path, files)?; + } else if metadata.is_file() { + let relative = path + .strip_prefix(root) + .map_err(|_| PackageError::UnsafeClosure)?; + let relative = relative.to_str().ok_or(PackageError::UnsafeClosure)?; + validate_relative(relative)?; + if !files.insert(relative.to_owned()) || files.len() > MAX_PACKAGE_FILES + 1 { + return Err(PackageError::ClosureBound); + } + } else { + return Err(PackageError::Verification); + } + } + Ok(()) + } + + let mut files = BTreeSet::new(); + visit(root, root, &mut files)?; + Ok(files) +} + +#[cfg(unix)] +fn safe_permissions(metadata: &fs::Metadata) -> bool { + use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _}; + + trusted_unix_owner_and_mode( + metadata.uid(), + metadata.permissions().mode(), + current_effective_user(), + false, + ) +} + +#[cfg(not(unix))] +fn safe_permissions(_metadata: &fs::Metadata) -> bool { + false +} + +#[cfg(unix)] +fn safe_ancestor_permissions(metadata: &fs::Metadata, effective_user: u32) -> bool { + use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _}; + + trusted_unix_owner_and_mode( + metadata.uid(), + metadata.permissions().mode(), + effective_user, + true, + ) +} + +#[cfg(not(unix))] +fn safe_ancestor_permissions(_metadata: &fs::Metadata, _effective_user: u32) -> bool { + false +} + +#[cfg(unix)] +fn trusted_unix_owner_and_mode( + owner: u32, + mode: u32, + effective_user: u32, + allow_root_sticky: bool, +) -> bool { + let trusted_owner = owner == 0 || owner == effective_user; + let not_writable_by_others = mode & 0o022 == 0; + let protected_shared_ancestor = allow_root_sticky && owner == 0 && mode & 0o1000 != 0; + trusted_owner && (not_writable_by_others || protected_shared_ancestor) +} + +#[cfg(unix)] +fn current_effective_user() -> u32 { + rustix::process::geteuid().as_raw() +} + +#[cfg(not(unix))] +fn current_effective_user() -> u32 { + 0 +} + +#[cfg(unix)] +fn harden_package_permissions(root: &Path) -> Result<(), PackageError> { + use std::os::unix::fs::PermissionsExt; + + fn visit(path: &Path) -> Result<(), PackageError> { + let metadata = fs::symlink_metadata(path).map_err(|_| PackageError::Write)?; + if metadata.file_type().is_symlink() { + return Err(PackageError::UnsafeClosure); + } + let mode = if metadata.is_dir() { 0o755 } else { 0o644 }; + fs::set_permissions(path, fs::Permissions::from_mode(mode)) + .map_err(|_| PackageError::Write)?; + if metadata.is_dir() { + for entry in fs::read_dir(path).map_err(|_| PackageError::Write)? { + visit(&entry.map_err(|_| PackageError::Write)?.path())?; + } + } + Ok(()) + } + visit(root) +} + +#[cfg(not(unix))] +fn harden_package_permissions(_root: &Path) -> Result<(), PackageError> { + Ok(()) +} + +fn write_generated(root: &Path, artifact: &GeneratedArtifact) -> Result<(), PackageError> { + validate_relative(&artifact.path)?; + write_new_file( + &root.join("generated").join(&artifact.path), + &artifact.content, + ) +} + +fn write_new_file(path: &Path, content: &[u8]) -> Result<(), PackageError> { + if path.exists() { + return Err(PackageError::Write); + } + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|_| PackageError::Write)?; + } + fs::write(path, content).map_err(|_| PackageError::Write) +} + +fn file_entry( + path: &str, + content: &[u8], + media_type: &str, + visibility: Visibility, + generated: bool, +) -> PackageFile { + PackageFile { + path: path.into(), + size: content.len() as u64, + sha256: digest(content), + media_type: media_type.into(), + visibility, + generated, + } +} + +fn digest(content: &[u8]) -> String { + format!("sha256:{}", hex::encode(Sha256::digest(content))) +} + +fn media_type(path: &str) -> &'static str { + match Path::new(path) + .extension() + .and_then(|extension| extension.to_str()) + { + Some("json") | Some("jsonld") => "application/json", + Some("ttl") => "text/turtle", + _ => "application/yaml", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn package_references_cannot_escape() { + assert!(validate_relative("governance/review.yaml").is_ok()); + assert!(validate_relative("../outside.yaml").is_err()); + assert!(validate_relative("/absolute.yaml").is_err()); + } + + #[cfg(unix)] + #[test] + fn governed_capture_rejects_intermediate_symlinks() { + use std::os::unix::fs::symlink; + + let temporary = tempfile::tempdir().expect("temporary project"); + let project = temporary.path().join("project"); + let governed = crate::compiler::tests::governed_files(); + for (relative, content) in &governed { + let relative = Path::new(relative); + let path = if relative.starts_with("governance") { + project.join("real-governance").join( + relative + .strip_prefix("governance") + .expect("governance prefix"), + ) + } else { + project.join(relative) + }; + fs::create_dir_all(path.parent().expect("governed parent")) + .expect("governed directory"); + fs::write(path, content).expect("governed file"); + } + symlink(project.join("real-governance"), project.join("governance")) + .expect("intermediate symlink"); + let contract = RegistryContract::parse_yaml(crate::compiler::tests::valid_contract()) + .expect("strict contract"); + + assert!(matches!( + capture_governed_closure(&project, &contract), + Err(PackageError::UnsafeClosure) + )); + } + + #[cfg(unix)] + #[test] + fn package_trust_rejects_foreign_owners_and_limits_the_sticky_exception() { + let effective_user = 1000; + assert!(trusted_unix_owner_and_mode( + effective_user, + 0o100644, + effective_user, + false + )); + assert!(trusted_unix_owner_and_mode( + 0, + 0o040755, + effective_user, + false + )); + assert!(!trusted_unix_owner_and_mode( + effective_user + 1, + 0o100644, + effective_user, + false + )); + assert!(trusted_unix_owner_and_mode( + 0, + 0o041777, + effective_user, + true + )); + assert!(!trusted_unix_owner_and_mode( + 0, + 0o041777, + effective_user, + false + )); + assert!(!trusted_unix_owner_and_mode( + effective_user, + 0o041777, + effective_user, + true + )); + } + + #[cfg(unix)] + #[test] + fn a_package_below_a_writable_ancestor_is_rejected() { + use std::os::unix::fs::PermissionsExt as _; + + let temporary = tempfile::tempdir().expect("temporary root"); + let root = temporary.path().canonicalize().expect("canonical root"); + let writable = root.join("writable"); + let package = writable.join("package"); + fs::create_dir_all(&package).expect("package path"); + fs::set_permissions(&writable, fs::Permissions::from_mode(0o777)) + .expect("ancestor becomes unsafe"); + + assert!(matches!( + reject_symlink_path(&package), + Err(PackageError::UnsafeClosure) + )); + } + + #[test] + fn sealed_package_reproduces_and_tampering_is_refused() { + let temporary = tempfile::tempdir().expect("temporary project"); + let project = temporary.path().join("project"); + fs::create_dir(&project).expect("project directory"); + fs::write( + project.join("registry.yaml"), + crate::compiler::tests::valid_contract(), + ) + .expect("registry contract"); + let governed = crate::compiler::tests::governed_files(); + for (relative, content) in &governed { + let path = project.join(relative); + fs::create_dir_all(path.parent().expect("parent")).expect("governed directory"); + fs::write(path, content).expect("governed file"); + } + let contract = RegistryContract::parse_yaml(crate::compiler::tests::valid_contract()) + .expect("strict contract"); + let registry = compile_contract_with_governed_files( + &contract, + &[crate::compiler::tests::observed_schema()], + CompileProfile::Production, + &governed, + ) + .expect("compiled Registry"); + let artifacts = generate_artifacts(®istry).expect("artifacts"); + let output = temporary.path().join("package"); + let manifest = build_package(&project, &output, &contract, ®istry, &artifacts) + .expect("sealed package"); + // macOS places temporary directories below `/var`, which is itself a + // symlink. The production loader correctly refuses paths containing + // symlink traversal, so exercise it with the resolved package path. + let resolved_output = output.canonicalize().expect("resolved package path"); + let verified = load_package(&resolved_output).expect("verified package"); + assert_eq!(verified.manifest, manifest); + assert_eq!(verified.registry, registry); + + let artifact = &manifest.artifacts[0]; + fs::write(output.join(&artifact.path), b"tampered").expect("tamper fixture"); + assert!(load_package(&resolved_output).is_err()); + } +} diff --git a/crates/registry-relay-v2/src/problem.rs b/crates/registry-relay-v2/src/problem.rs new file mode 100644 index 000000000..9f0bbf27a --- /dev/null +++ b/crates/registry-relay-v2/src/problem.rs @@ -0,0 +1,406 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Value-free RFC 9457 problems for Relay V2. +//! +//! The public `code` is a Registry Stack identifier rather than an internal +//! error. Callers must never attach rejected selector values, SQL, source +//! paths, token material, or principal identifiers to a problem. + +use axum::body::Body; +use axum::http::header::{CACHE_CONTROL, CONTENT_TYPE, WWW_AUTHENTICATE}; +use axum::http::{HeaderMap, HeaderName, HeaderValue, Response, StatusCode}; +use serde::Serialize; +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, + FieldsInvalid, + UnknownFilter, + InvalidFilter, + CursorInvalid, + MissingCredential, + InvalidCredential, + ConsultationDenied, + ResourceNotFound, + ConsultationUnresolved, + UnsupportedRepresentation, + BodyTooLarge, + UriTooLong, + UnsupportedMediaType, + RateLimited, + Internal, + SourceUnavailable, + AuditUnavailable, + ServiceNotReady, + Timeout, +} + +impl ProblemCode { + #[must_use] + pub const fn code(self) -> &'static str { + match self { + Self::ConsultationInvalidRequest => "consultation.invalid_request", + Self::FieldsInvalid => "request.fields_invalid", + Self::UnknownFilter => "filter.unknown_field", + Self::InvalidFilter => "filter.invalid_value", + Self::CursorInvalid => "query.cursor_invalid", + Self::MissingCredential => "auth.missing_credential", + Self::InvalidCredential => "auth.invalid_credential", + Self::ConsultationDenied => "consultation.denied", + Self::ResourceNotFound => "resource.not_found", + Self::ConsultationUnresolved => "consultation.unresolved", + Self::UnsupportedRepresentation => "representation.unsupported", + Self::BodyTooLarge => "internal.payload_too_large", + Self::UriTooLong => "internal.uri_too_long", + Self::UnsupportedMediaType => "request.media_type_unsupported", + Self::RateLimited => "consultation.rate_limited", + Self::SourceUnavailable => "source.unavailable", + Self::AuditUnavailable => "audit.unavailable", + Self::Internal => "internal.unhandled", + Self::ServiceNotReady => "service.not_ready", + Self::Timeout => "internal.timeout", + } + } + + #[must_use] + pub const fn title(self) -> &'static str { + match self { + Self::ConsultationInvalidRequest => "Consultation 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::MissingCredential => "Bearer access token is required", + Self::InvalidCredential => "Bearer access token is invalid", + Self::ConsultationDenied => "Consultation is not permitted", + Self::ResourceNotFound => "Requested resource was not found", + Self::ConsultationUnresolved => "Requested record was not resolved", + Self::UnsupportedRepresentation => "Requested representation is not supported", + Self::BodyTooLarge => "Request body is too large", + Self::UriTooLong => "Request URI is too long", + Self::UnsupportedMediaType => "Request media type is not supported", + Self::RateLimited => "Consultation 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", + } + } + + #[must_use] + pub const fn status(self) -> u16 { + match self { + Self::ConsultationInvalidRequest + | Self::FieldsInvalid + | Self::UnknownFilter + | Self::InvalidFilter + | Self::CursorInvalid => 400, + Self::MissingCredential | Self::InvalidCredential => 401, + Self::ConsultationDenied => 403, + Self::ResourceNotFound | Self::ConsultationUnresolved => 404, + Self::UnsupportedRepresentation => 406, + Self::BodyTooLarge => 413, + Self::UriTooLong => 414, + Self::UnsupportedMediaType => 415, + Self::RateLimited => 429, + Self::SourceUnavailable | Self::AuditUnavailable => 503, + Self::ServiceNotReady => 503, + Self::Timeout => 504, + Self::Internal => 500, + } + } + + #[must_use] + pub fn type_uri(self) -> String { + format!("{PROBLEM_BASE}{}", self.code().replace('.', "/")) + } + + #[must_use] + pub fn body(self, trace_id: TraceId) -> ProblemBody { + ProblemBody { + type_uri: self.type_uri(), + title: self.title(), + status: self.status(), + detail: self.detail(), + code: self.code(), + trace_id, + } + } + + /// Serialize the one fixed public failure body and attach the effective + /// W3C trace context. No rejected value is accepted by this API. + #[must_use] + pub fn response(self, trace: &TraceContext) -> Response { + let bytes = serde_json::to_vec(&self.body(trace.trace_id.clone())) + .unwrap_or_else(|_| b"{}".to_vec()); + let mut response = Response::new(Body::from(bytes)); + *response.status_mut() = + StatusCode::from_u16(self.status()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); + let headers = response.headers_mut(); + headers.insert( + CONTENT_TYPE, + HeaderValue::from_static("application/problem+json"), + ); + headers.insert(CACHE_CONTROL, HeaderValue::from_static("no-store")); + if matches!(self, Self::MissingCredential | Self::InvalidCredential) { + headers.insert( + WWW_AUTHENTICATE, + HeaderValue::from_static("Bearer realm=\"registry-relay\""), + ); + } + if self == Self::RateLimited { + headers.insert("retry-after", HeaderValue::from_static("60")); + } + trace.apply(headers); + response + } + + const fn detail(self) -> &'static str { + match self { + Self::ConsultationInvalidRequest => "the consultation 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::MissingCredential => "a bearer access token is required", + Self::InvalidCredential => "bearer access token validation failed", + Self::ConsultationDenied => "the consultation is not permitted", + Self::ResourceNotFound => "the requested resource was not found", + Self::ConsultationUnresolved => "the requested record was not resolved", + Self::UnsupportedRepresentation => "the requested representation is not supported", + Self::BodyTooLarge => "request body exceeds the configured 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::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. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(transparent)] +pub struct TraceId(String); + +impl TraceId { + /// Parse the 32 lower-case hexadecimal trace-id representation. + pub fn parse(value: &str) -> Result { + if value.len() != 32 + || !value + .bytes() + .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) + { + return Err(TraceIdError); + } + Ok(Self(value.to_owned())) + } + + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// Effective request trace. An invalid `traceparent` is replaced with a +/// server-created context. Caller-supplied `tracestate` never enters it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TraceContext { + pub trace_id: TraceId, + parent_id: String, + trace_flags: String, +} + +impl TraceContext { + #[must_use] + pub fn from_headers(headers: &HeaderMap) -> Self { + headers + .get("traceparent") + .and_then(|value| value.to_str().ok()) + .and_then(parse_traceparent) + .unwrap_or_else(Self::server_created) + } + + #[must_use] + pub fn server_created() -> Self { + let value = u128::from(Ulid::new()); + let trace_id = TraceId(format!("{value:032x}")); + let parent = u64::try_from(value & u128::from(u64::MAX)).unwrap_or(1); + Self { + trace_id, + parent_id: format!("{:016x}", parent.max(1)), + trace_flags: "01".into(), + } + } + + pub fn apply(&self, headers: &mut HeaderMap) { + // Relay's value-free response boundary never reflects caller-controlled + // vendor state, even when the incoming value is syntactically valid. + headers.remove("tracestate"); + let traceparent = format!( + "00-{}-{}-{}", + self.trace_id.as_str(), + self.parent_id, + self.trace_flags + ); + if let Ok(value) = HeaderValue::from_str(&traceparent) { + headers.insert(HeaderName::from_static("traceparent"), value); + } + } +} + +fn parse_traceparent(value: &str) -> Option { + if !value.is_ascii() || value.len() != 55 { + return None; + } + let parts = value.split('-').collect::>(); + if parts.len() != 4 || parts[0] != "00" { + return None; + } + let trace = parts[1]; + let parent = parts[2]; + let flags = parts[3]; + if !lower_hex(trace, 32) + || trace.bytes().all(|byte| byte == b'0') + || !lower_hex(parent, 16) + || parent.bytes().all(|byte| byte == b'0') + || !lower_hex(flags, 2) + { + return None; + } + Some(TraceContext { + trace_id: TraceId(trace.to_owned()), + parent_id: parent.to_owned(), + trace_flags: flags.to_owned(), + }) +} + +fn lower_hex(value: &str, length: usize) -> bool { + value.len() == length + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)] +#[error("trace identifier is invalid")] +pub struct TraceIdError; + +/// The fixed safe HTTP problem representation. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProblemBody { + #[serde(rename = "type")] + pub type_uri: String, + pub title: &'static str, + pub status: u16, + pub detail: &'static str, + pub code: &'static str, + pub trace_id: TraceId, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unresolved_lookup_causes_have_one_public_body() { + let trace = TraceId::parse("0123456789abcdef0123456789abcdef").expect("trace parses"); + let body = ProblemCode::ConsultationUnresolved.body(trace); + assert_eq!(body.status, 404); + assert_eq!(body.code, "consultation.unresolved"); + assert_eq!( + body.type_uri, + "https://id.registrystack.org/problems/registry-relay/consultation/unresolved" + ); + } + + #[test] + fn trace_identifier_has_one_canonical_wire_shape() { + assert!(TraceId::parse("0123456789abcdef0123456789abcdef").is_ok()); + assert!(TraceId::parse("not-a-trace").is_err()); + } + + #[test] + fn version_zero_traceparent_rejects_non_lowercase_hex() { + for value in [ + "00-0123456789abcdeF0123456789abcdef-0123456789abcdef-01", + "00-0123456789abcdef0123456789abcdef-0123456789abcdeF-01", + "00-0123456789abcdef0123456789abcdef-0123456789abcdef-0A", + ] { + assert!( + parse_traceparent(value).is_none(), + "accepted non-lowercase traceparent {value}" + ); + } + } + + #[test] + fn invalid_traceparent_is_replaced_with_server_context() { + let supplied_trace_id = "0123456789abcdef0123456789abcdeF"; + let mut headers = HeaderMap::new(); + headers.insert( + "traceparent", + HeaderValue::from_str(&format!("00-{supplied_trace_id}-0123456789abcdef-00")) + .expect("test traceparent is an HTTP header value"), + ); + headers.insert( + "tracestate", + HeaderValue::from_static("vendor=caller-controlled-canary"), + ); + + let trace = TraceContext::from_headers(&headers); + + assert_ne!( + trace.trace_id.as_str(), + supplied_trace_id.to_ascii_lowercase() + ); + assert_eq!(trace.trace_flags, "01"); + let mut response_headers = HeaderMap::new(); + trace.apply(&mut response_headers); + assert_ne!( + response_headers + .get("traceparent") + .expect("server context is applied") + .to_str() + .expect("traceparent is ASCII"), + format!( + "00-{}-0123456789abcdef-00", + supplied_trace_id.to_ascii_lowercase() + ) + ); + } + + #[test] + fn caller_tracestate_is_never_echoed_in_ordinary_or_problem_headers() { + const CANARY: &str = "7tenant@vendor-system=caller-controlled-canary"; + let mut request_headers = HeaderMap::new(); + request_headers.insert( + "traceparent", + HeaderValue::from_static("00-0123456789abcdef0123456789abcdef-0123456789abcdef-01"), + ); + request_headers.insert("tracestate", HeaderValue::from_static(CANARY)); + let trace = TraceContext::from_headers(&request_headers); + + let mut ordinary_headers = HeaderMap::new(); + ordinary_headers.insert("tracestate", HeaderValue::from_static(CANARY)); + trace.apply(&mut ordinary_headers); + assert!(!ordinary_headers.contains_key("tracestate")); + + let response = ProblemCode::Internal.response(&trace); + assert!(!response.headers().contains_key("tracestate")); + assert!(response.headers().values().all(|value| { + !value + .to_str() + .expect("response headers are ASCII") + .contains("caller-controlled-canary") + })); + } +} diff --git a/crates/registry-relay-v2/src/semantics.rs b/crates/registry-relay-v2/src/semantics.rs new file mode 100644 index 000000000..b6b76d53c --- /dev/null +++ b/crates/registry-relay-v2/src/semantics.rs @@ -0,0 +1,418 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Deterministic local semantic and validation artifact construction. + +use serde_json::{json, Map, Value}; + +use crate::contract::DataType; +use crate::model::{CompiledProperty, CompiledRegistry, CompiledResource}; + +pub fn local_vocabulary( + registry: &CompiledRegistry, + resource: &CompiledResource, + selected: &[String], +) -> Value { + let mut graph = Vec::new(); + graph.push(json!({ + "@id": resource.semantic_class, + "@type": "rdfs:Class", + "rdfs:label": resource.title, + "rdfs:comment": resource.description, + })); + for property in selected_properties(resource, selected) { + graph.push(json!({ + "@id": property.semantic_iri, + "@type": "rdf:Property", + "rdfs:label": property.label, + "rdfs:comment": property.description, + "rdfs:domain": {"@id": resource.semantic_class}, + "rdfs:range": {"@id": datatype_iri(property.data_type)}, + "https://id.registrystack.org/vocab/sourceRequired": property.source_required, + "https://id.registrystack.org/vocab/codelist": property.codelist, + })); + } + json!({ + "@context": { + "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#", + "rdfs": "http://www.w3.org/2000/01/rdf-schema#", + "xsd": "http://www.w3.org/2001/XMLSchema#" + }, + "@id": registry.local_vocabulary, + "@graph": graph, + }) +} + +pub fn json_ld_context( + registry: &CompiledRegistry, + resource: &CompiledResource, + selected: &[String], +) -> Value { + let core = "https://id.registrystack.org/vocab/core/"; + let mut context = Map::new(); + context.insert("@version".into(), json!(1.1)); + context.insert("@vocab".into(), json!(registry.local_vocabulary)); + for field in [ + "registryIdentifier", + "schemaReference", + "semanticModelReference", + "authorityIdentifier", + ] { + context.insert( + field.into(), + json!({"@id": format!("{core}{field}"), "@type": "@id"}), + ); + } + for field in [ + "recordIdentifier", + "revisionIdentifier", + "lifecycleState", + "recordedAt", + "domainData", + ] { + context.insert(field.into(), json!(format!("{core}{field}"))); + } + for property in selected_properties(resource, selected) { + context.insert( + property.name.clone(), + json!({"@id": property.semantic_iri, "@nest": "domainData"}), + ); + } + // Transport-only envelope members never acquire semantic meaning. + for field in ["data", "items", "pageInfo", "nextCursor", "meta"] { + context.insert(field.into(), Value::Null); + } + json!({"@context": context}) +} + +pub fn representation_schema( + registry: &CompiledRegistry, + resource: &CompiledResource, + selected: &[String], + schema_reference: &str, + semantic_model_reference: &str, +) -> Value { + record_schema( + registry, + resource, + selected, + false, + schema_reference, + semantic_model_reference, + ) +} + +pub fn full_record_schema(registry: &CompiledRegistry, resource: &CompiledResource) -> Value { + let selected = resource + .properties + .iter() + .map(|property| property.name.clone()) + .collect::>(); + record_schema( + registry, + resource, + &selected, + true, + &resource.record_context.schema_reference, + &resource.record_context.semantic_model_reference, + ) +} + +fn record_schema( + registry: &CompiledRegistry, + resource: &CompiledResource, + selected: &[String], + full: bool, + schema_reference: &str, + semantic_model_reference: &str, +) -> Value { + let lifecycle_values = registry + .codelists + .iter() + .find(|item| item.path == resource.record_context.lifecycle_state_codelist) + .map(|item| item.values.clone()) + .unwrap_or_default(); + let lifecycle_schema = if lifecycle_values.is_empty() { + json!({"type": "string", "minLength": 1}) + } else { + json!({"type": "string", "enum": lifecycle_values}) + }; + let mut domain_properties = Map::new(); + let mut domain_required = Vec::new(); + for property in selected_properties(resource, selected) { + let mut schema = property_schema(registry, property); + if let Value::Object(map) = &mut schema { + map.insert("title".into(), json!(property.label)); + map.insert("description".into(), json!(property.description)); + } + domain_properties.insert(property.name.clone(), schema); + if full && property.source_required { + domain_required.push(Value::String(property.name.clone())); + } + } + let mut domain_data = json!({ + "type": "object", + "additionalProperties": false, + "properties": domain_properties, + }); + if full { + domain_data + .as_object_mut() + .expect("object") + .insert("required".into(), Value::Array(domain_required)); + } + json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": schema_reference, + "title": resource.title, + "type": "object", + "additionalProperties": false, + "required": [ + "registryIdentifier", "recordIdentifier", "revisionIdentifier", + "lifecycleState", "schemaReference", "semanticModelReference", + "authorityIdentifier", "recordedAt", "domainData" + ], + "properties": { + "registryIdentifier": {"const": registry.registry_identifier}, + "recordIdentifier": {"type": "string", "minLength": 1}, + "revisionIdentifier": {"type": "string", "minLength": 1}, + "lifecycleState": lifecycle_schema, + "schemaReference": {"const": schema_reference}, + "semanticModelReference": {"const": semantic_model_reference}, + "authorityIdentifier": {"const": registry.authority_identifier}, + "recordedAt": {"type": "string", "format": "date-time"}, + "domainData": domain_data + } + }) +} + +pub fn representation_shacl( + registry: &CompiledRegistry, + resource: &CompiledResource, + selected: &[String], +) -> String { + shacl(registry, resource, selected, false) +} + +pub fn full_record_shacl(registry: &CompiledRegistry, resource: &CompiledResource) -> String { + let selected = resource + .properties + .iter() + .map(|property| property.name.clone()) + .collect::>(); + shacl(registry, resource, &selected, true) +} + +fn shacl( + registry: &CompiledRegistry, + resource: &CompiledResource, + selected: &[String], + full: bool, +) -> String { + let mut output = format!( + "@prefix sh: .\n@prefix xsd: .\n\n<{}shapes/{}> a sh:NodeShape ;\n sh:targetClass <{}> ;\n sh:closed true", + registry.local_vocabulary, resource.id, resource.semantic_class + ); + for (path, datatype) in [ + ( + "registryIdentifier", + "http://www.w3.org/2001/XMLSchema#anyURI", + ), + ( + "recordIdentifier", + "http://www.w3.org/2001/XMLSchema#string", + ), + ( + "revisionIdentifier", + "http://www.w3.org/2001/XMLSchema#string", + ), + ("lifecycleState", "http://www.w3.org/2001/XMLSchema#string"), + ("schemaReference", "http://www.w3.org/2001/XMLSchema#anyURI"), + ( + "semanticModelReference", + "http://www.w3.org/2001/XMLSchema#anyURI", + ), + ( + "authorityIdentifier", + "http://www.w3.org/2001/XMLSchema#anyURI", + ), + ("recordedAt", "http://www.w3.org/2001/XMLSchema#dateTime"), + ] { + output.push_str(&format!( + " ;\n sh:property [ sh:path ; sh:datatype <{datatype}> ; sh:minCount 1 ; sh:maxCount 1 ]" + )); + } + for property in selected_properties(resource, selected) { + let controlled_values = property + .codelist + .as_deref() + .and_then(|path| registry.codelists.iter().find(|item| item.path == path)) + .map(|codelist| { + format!( + " ; sh:in ( {} )", + codelist + .values + .iter() + .map(|value| format!("\"{}\"", turtle_escape(value))) + .collect::>() + .join(" ") + ) + }) + .unwrap_or_default(); + output.push_str(&format!( + " ;\n sh:property [ sh:path <{}> ; sh:datatype <{}>{} ; sh:minCount {} ; sh:maxCount 1 ]", + property.semantic_iri, + datatype_iri(property.data_type), + controlled_values, + usize::from(full && property.source_required) + )); + } + output.push_str(" .\n"); + output +} + +fn property_schema(registry: &CompiledRegistry, property: &CompiledProperty) -> Value { + match property.data_type { + DataType::String => json!({"type": "string"}), + DataType::ControlledCode => { + let values = property + .codelist + .as_deref() + .and_then(|path| registry.codelists.iter().find(|item| item.path == path)) + .map(|codelist| codelist.values.clone()) + .unwrap_or_default(); + if values.is_empty() { + json!({"type": "string", "x-registry-codelist": property.codelist}) + } else { + json!({"type": "string", "enum": values, "x-registry-codelist": property.codelist}) + } + } + DataType::Boolean => json!({"type": "boolean"}), + DataType::Integer => json!({"type": "integer"}), + DataType::Date => json!({"type": "string", "format": "date"}), + DataType::DateTime => json!({"type": "string", "format": "date-time"}), + } +} + +fn turtle_escape(value: &str) -> String { + value + .replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('\n', "\\n") + .replace('\r', "\\r") +} + +pub fn datatype_iri(data_type: DataType) -> &'static str { + match data_type { + DataType::String | DataType::ControlledCode => "http://www.w3.org/2001/XMLSchema#string", + DataType::Boolean => "http://www.w3.org/2001/XMLSchema#boolean", + DataType::Integer => "http://www.w3.org/2001/XMLSchema#integer", + DataType::Date => "http://www.w3.org/2001/XMLSchema#date", + DataType::DateTime => "http://www.w3.org/2001/XMLSchema#dateTime", + } +} + +fn selected_properties<'a>( + resource: &'a CompiledResource, + selected: &[String], +) -> Vec<&'a CompiledProperty> { + resource + .properties + .iter() + .filter(|property| selected.contains(&property.name)) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn transport_envelope_is_null_in_context() { + let context = json_ld_context(®istry(), &resource(), &["name".into()]); + assert!(context["@context"]["meta"].is_null()); + assert_eq!(context["@context"]["name"]["@nest"], "domainData"); + } + + fn resource() -> CompiledResource { + use crate::contract::{Handling, ReviewStatus}; + use crate::model::*; + CompiledResource { + id: "record".into(), + title: "Record".into(), + description: "Record".into(), + semantic_class: "https://example.invalid/vocab/Record".into(), + source: "db".into(), + view: "records".into(), + record_context: CompiledRecordContext { + record_identifier_column: "id".into(), + revision_identifier_column: "rev".into(), + lifecycle_state_column: "state".into(), + lifecycle_state_codelist: "state.yaml".into(), + recorded_at_column: "at".into(), + schema_reference: "https://example.invalid/artifacts/record.schema.json".into(), + semantic_model_reference: + "https://example.invalid/artifacts/record.vocabulary.jsonld".into(), + }, + properties: vec![CompiledProperty { + name: "name".into(), + label: "Name".into(), + description: "Name".into(), + source_column: "name".into(), + data_type: DataType::String, + codelist: None, + source_required: true, + semantic_iri: "https://example.invalid/vocab/name".into(), + classification: EffectiveClassification { + privacy: "non-personal".into(), + privacy_scheme: "urn:p".into(), + privacy_version: "1".into(), + institutional: "public".into(), + institutional_scheme: "urn:i".into(), + institutional_version: "1".into(), + handling: Handling::Public, + handling_scheme: "urn:h".into(), + handling_version: "1".into(), + status: ReviewStatus::Reviewed, + provenance_ref: "review.yaml".into(), + }, + }], + disclosure_profiles: Vec::new(), + operations: Vec::new(), + column_accounting: Vec::new(), + processing_descriptions: Vec::new(), + } + } + + fn registry() -> CompiledRegistry { + use crate::contract::Visibility; + use crate::model::CompiledMetadataVisibility; + CompiledRegistry { + contract_revision: "sha256:test".into(), + contract_id: "test".into(), + contract_version: "1".into(), + registry_identifier: "urn:example:registry".into(), + registry_name: "Registry".into(), + authority_identifier: "urn:example:authority".into(), + operator_identifier: None, + authoritative_scope: "scope".into(), + base_uri: "https://example.invalid/".into(), + identifier_lifecycle_policy_ref: "governance/id.yaml".into(), + alignment_targets: Vec::new(), + controller_identifier: "urn:example:authority".into(), + publisher_identifier: "urn:example:authority".into(), + audit_owner_identifier: "urn:example:audit".into(), + local_vocabulary: "https://example.invalid/vocab/".into(), + semantic_alignments: Vec::new(), + governed_files: Vec::new(), + codelists: Vec::new(), + sources: Vec::new(), + resources: Vec::new(), + metadata_visibility: CompiledMetadataVisibility { + service: Visibility::Public, + resources: Visibility::Public, + semantics: Visibility::Public, + classifications: Visibility::Public, + processing: Visibility::Public, + }, + } + } +} diff --git a/crates/registry-relay-v2/src/server.rs b/crates/registry-relay-v2/src/server.rs new file mode 100644 index 000000000..c24b1c243 --- /dev/null +++ b/crates/registry-relay-v2/src/server.rs @@ -0,0 +1,371 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Relay V2 HTTP service composition and process-local resource bounds. + +use std::collections::BTreeMap; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use axum::routing::{get, post}; +use axum::Router; +use axum::{body::Body, http::Request}; +use registry_platform_httpsec::{security_headers, CspBuilder}; +use tower_http::trace::TraceLayer; + +use crate::artifacts::ArtifactSet; +use crate::audit::RelayAudit; +use crate::auth::RelayAuthenticator; +use crate::cursor::CursorKey; +use crate::model::CompiledRegistry; +use crate::sqlite_runtime::SqliteRuntime; + +const MAXIMUM_URI_BYTES: usize = 16 * 1024; + +#[derive(Clone, Debug)] +pub struct InstitutionMetadata { + pub identifier: String, + pub name: String, +} + +#[derive(Clone, Debug)] +pub struct AlignmentMetadata { + pub name: String, + pub version: String, + pub status: String, + pub cfr_target: Option, +} + +#[derive(Clone, Debug)] +pub struct ServiceMetadata { + pub authority: InstitutionMetadata, + pub operator: Option, + pub authoritative_scope: String, + pub alignment_targets: Vec, +} + +#[derive(Clone, Debug)] +pub struct QuotaConfig { + pub requests_per_minute: u32, + pub burst: u32, +} + +#[derive(Clone)] +pub struct RelayService { + pub registry: Arc, + pub artifacts: Arc, + pub sqlite: Arc, + pub authenticator: Option, + pub audit: RelayAudit, + pub cursor_key: Option>, + pub cursor_maximum_age: Duration, + pub request_timeout: Duration, + pub metadata: ServiceMetadata, + pub(crate) quota: Option>, +} + +impl RelayService { + #[allow(clippy::too_many_arguments)] + #[must_use] + pub fn new( + registry: Arc, + artifacts: Arc, + sqlite: Arc, + authenticator: Option, + audit: RelayAudit, + cursor_key: Option>, + cursor_maximum_age: Duration, + request_timeout: Duration, + quota: Option, + metadata: ServiceMetadata, + ) -> Self { + Self { + registry, + artifacts, + sqlite, + authenticator, + audit, + cursor_key, + cursor_maximum_age, + request_timeout, + metadata, + quota: quota.map(|config| Arc::new(QuotaLimiter::new(config))), + } + } + + #[must_use] + pub async fn is_ready(&self) -> bool { + let audit = self.audit.ready(); + let source = self.sqlite.is_ready(); + let issuer = async { + match &self.authenticator { + Some(authenticator) => authenticator.is_ready().await, + None => true, + } + }; + let (audit_ready, source_ready, issuer_ready) = tokio::join!(audit, source, issuer); + audit_ready && source_ready && issuer_ready + } +} + +/// Construct the fixed V2 route inventory. Individual data operations remain +/// compiler-confined by handler dispatch against the immutable model. +pub fn router(service: Arc) -> Router { + Router::new() + .route("/health", get(crate::api::health)) + .route("/ready", get(crate::api::ready)) + .route("/openapi.json", get(crate::api::openapi)) + .route("/v2", get(crate::api::service_metadata)) + .route("/v2/resources", get(crate::api::resource_list)) + .route( + "/v2/resources/{resource}", + get(crate::api::resource_metadata), + ) + .route( + "/v2/resources/{resource}/records", + get(crate::api::record_list), + ) + .route( + "/v2/resources/{resource}/records/{record_identifier}", + get(crate::api::record_read), + ) + .route( + "/v2/resources/{resource}/lookups/{lookup}", + post(crate::api::record_lookup), + ) + .route( + "/v2/artifacts/{artifact_identifier}", + get(crate::api::artifact), + ) + .fallback(crate::api::not_found) + .with_state(service) + .layer( + TraceLayer::new_for_http() + .make_span_with(|request: &Request| { + tracing::info_span!( + target: "registry_relay_v2::http", + "http.request", + method = operational_method(request.method()), + route = operational_route(request.uri()), + ) + }) + .on_response( + |response: &http::Response, latency: Duration, span: &tracing::Span| { + tracing::info!( + parent: span, + status = response.status().as_u16(), + latency_milliseconds = bounded_milliseconds(latency), + trace_id = response_trace_id(response.headers()).unwrap_or("none"), + "request completed" + ); + }, + ), + ) + .layer(security_headers(CspBuilder::restrictive())) +} + +fn operational_method(method: &http::Method) -> &'static str { + match method.as_str() { + "GET" => "GET", + "POST" => "POST", + "PUT" => "PUT", + "DELETE" => "DELETE", + "PATCH" => "PATCH", + "HEAD" => "HEAD", + "OPTIONS" => "OPTIONS", + "CONNECT" => "CONNECT", + "TRACE" => "TRACE", + _ => "OTHER", + } +} + +/// Classify only the fixed route shape. Dynamic identifiers and query values +/// never cross the operational logging boundary. +fn operational_route(uri: &http::Uri) -> &'static str { + let mut segments = uri.path().trim_start_matches('/').split('/'); + let parts = ( + segments.next(), + segments.next(), + segments.next(), + segments.next(), + segments.next(), + segments.next(), + segments.next(), + ); + if segments.next().is_some() { + return "unmatched"; + } + match parts { + (Some("health"), None, None, None, None, None, None) => "/health", + (Some("ready"), None, None, None, None, None, None) => "/ready", + (Some("openapi.json"), None, None, None, None, None, None) => "/openapi.json", + (Some("v2"), None, None, None, None, None, None) => "/v2", + (Some("v2"), Some("resources"), None, None, None, None, None) => "/v2/resources", + (Some("v2"), Some("resources"), Some(resource), None, None, None, None) + if !resource.is_empty() => + { + "/v2/resources/{resource}" + } + (Some("v2"), Some("resources"), Some(resource), Some("records"), None, None, None) + if !resource.is_empty() => + { + "/v2/resources/{resource}/records" + } + ( + Some("v2"), + Some("resources"), + Some(resource), + Some("records"), + Some(record_identifier), + None, + None, + ) if !resource.is_empty() && !record_identifier.is_empty() => { + "/v2/resources/{resource}/records/{record_identifier}" + } + ( + Some("v2"), + Some("resources"), + Some(resource), + Some("lookups"), + Some(lookup), + None, + None, + ) if !resource.is_empty() && !lookup.is_empty() => { + "/v2/resources/{resource}/lookups/{lookup}" + } + (Some("v2"), Some("artifacts"), Some(artifact_identifier), None, None, None, None) + if !artifact_identifier.is_empty() => + { + "/v2/artifacts/{artifact_identifier}" + } + _ => "unmatched", + } +} + +fn response_trace_id(headers: &http::HeaderMap) -> Option<&str> { + let value = headers.get("traceparent")?.to_str().ok()?; + let mut members = value.split('-'); + let version = members.next()?; + let trace_id = members.next()?; + let parent_id = members.next()?; + let flags = members.next()?; + if members.next().is_some() + || version.len() != 2 + || trace_id.len() != 32 + || parent_id.len() != 16 + || flags.len() != 2 + || !trace_id + .bytes() + .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) + { + return None; + } + Some(trace_id) +} + +fn bounded_milliseconds(duration: Duration) -> u64 { + u64::try_from(duration.as_millis()).unwrap_or(u64::MAX) +} + +#[derive(Debug)] +pub(crate) struct QuotaLimiter { + requests_per_minute: f64, + burst: f64, + states: Mutex>, +} + +#[derive(Debug)] +struct QuotaState { + tokens: f64, + observed_at: Instant, +} + +impl QuotaLimiter { + fn new(config: QuotaConfig) -> Self { + let burst = f64::from(config.burst.max(1)); + Self { + requests_per_minute: f64::from(config.requests_per_minute.max(1)), + burst, + states: Mutex::new(BTreeMap::new()), + } + } + + pub(crate) fn admit(&self, scope: &str) -> bool { + let now = Instant::now(); + let mut states = self + .states + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let state = states.entry(scope.to_owned()).or_insert(QuotaState { + tokens: self.burst, + observed_at: now, + }); + let elapsed = now.duration_since(state.observed_at).as_secs_f64(); + state.observed_at = now; + state.tokens = (state.tokens + elapsed * self.requests_per_minute / 60.0).min(self.burst); + if state.tokens < 1.0 { + return false; + } + state.tokens -= 1.0; + true + } +} + +#[must_use] +pub(crate) fn uri_within_bound(uri: &http::Uri) -> bool { + uri.to_string().len() <= MAXIMUM_URI_BYTES +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn quota_is_scoped_to_the_compiled_operation() { + let limiter = QuotaLimiter::new(QuotaConfig { + requests_per_minute: 1, + burst: 1, + }); + + assert!(limiter.admit("resource-a.read")); + assert!(!limiter.admit("resource-a.read")); + assert!(limiter.admit("resource-b.read")); + } + + #[test] + fn operational_dimensions_never_include_request_values() { + let uri = "/v2/resources/private-registry/records/protected-record?fields=secret" + .parse() + .expect("URI"); + assert_eq!( + operational_route(&uri), + "/v2/resources/{resource}/records/{record_identifier}" + ); + assert!(!operational_route(&uri).contains("private-registry")); + assert!(!operational_route(&uri).contains("protected-record")); + assert_eq!( + operational_method(&http::Method::from_bytes(b"ATTACKER-CONTROLLED").expect("method")), + "OTHER" + ); + } + + #[test] + fn operational_trace_identifier_is_closed_and_bounded() { + let mut headers = http::HeaderMap::new(); + headers.insert( + "traceparent", + "00-0123456789abcdef0123456789abcdef-0123456789abcdef-01" + .parse() + .expect("header"), + ); + assert_eq!( + response_trace_id(&headers), + Some("0123456789abcdef0123456789abcdef") + ); + headers.insert( + "traceparent", + "attacker-controlled-protected-value" + .parse() + .expect("header"), + ); + assert_eq!(response_trace_id(&headers), None); + } +} diff --git a/crates/registry-relay-v2/src/source_observation.rs b/crates/registry-relay-v2/src/source_observation.rs new file mode 100644 index 000000000..d36141d68 --- /dev/null +++ b/crates/registry-relay-v2/src/source_observation.rs @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Production source-schema observation shared by startup and adopter tooling. + +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use registry_platform_sqlite::{ + inspect_schema, CapturedSnapshot, DatabaseProfile, InspectionLimits, LiveDatabaseFile, + SchemaObjectKind, +}; + +use crate::contract::{RegistryContract, RelayRuntime, SourceProfile}; +use crate::model::{ObservedColumn, ObservedSourceSchema, ObservedView}; + +const MAXIMUM_OBJECTS: usize = 10_000; +const MAXIMUM_SQL_BYTES: usize = 8 * 1024 * 1024; +const MAXIMUM_STEPS: u64 = 1_000_000; +const TIMEOUT: Duration = Duration::from_secs(5); + +#[derive(Clone, Copy, Debug)] +pub(crate) struct SourceObservationError; + +pub(crate) fn observe_sources( + root: &Path, + contract: &RegistryContract, + runtime: &RelayRuntime, +) -> Result, SourceObservationError> { + let mut observed = Vec::new(); + for (source_id, source) in contract.sources.iter() { + let Some(binding) = runtime.sources.get(source_id) else { + continue; + }; + let path = resolve_source_path(root, &binding.path); + if !path.is_file() { + continue; + } + let profile = match source.profile { + SourceProfile::Snapshot => DatabaseProfile::Snapshot( + CapturedSnapshot::capture(&path).map_err(|_| SourceObservationError)?, + ), + SourceProfile::LiveReadOnly => DatabaseProfile::LiveReadOnly( + LiveDatabaseFile::bind(&path).map_err(|_| SourceObservationError)?, + ), + }; + let catalog = + inspect_schema(&profile, &inspection_limits()).map_err(|_| SourceObservationError)?; + let views = catalog + .objects + .iter() + .filter(|object| matches!(object.kind, SchemaObjectKind::View)) + .map(|object| ObservedView { + name: object.name.clone(), + columns: object + .columns + .iter() + .map(|column| ObservedColumn { + name: column.name.clone(), + declared_type: column.declared_type.clone(), + nullable: column.nullable, + primary_key: column.primary_key, + }) + .collect(), + }) + .collect(); + observed.push(ObservedSourceSchema { + source: source_id.into(), + fingerprint: catalog.fingerprint, + views, + }); + } + Ok(observed) +} + +pub(crate) fn inspection_limits() -> InspectionLimits { + InspectionLimits { + maximum_objects: MAXIMUM_OBJECTS, + maximum_sql_bytes: MAXIMUM_SQL_BYTES, + maximum_statement_steps: MAXIMUM_STEPS, + timeout: TIMEOUT, + } +} + +fn resolve_source_path(root: &Path, configured: &str) -> PathBuf { + let path = Path::new(configured); + if path.is_absolute() { + path.to_owned() + } else { + root.join(path) + } +} diff --git a/crates/registry-relay-v2/src/sqlite_runtime.rs b/crates/registry-relay-v2/src/sqlite_runtime.rs new file mode 100644 index 000000000..c98d32e79 --- /dev/null +++ b/crates/registry-relay-v2/src/sqlite_runtime.rs @@ -0,0 +1,572 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Concrete operation-keyed SQLite execution for the Relay V2 runtime. +//! +//! SQL is generated once from the immutable compiler model. Public requests +//! can supply values only for the named parameters already present in that +//! statement. There is deliberately no storage trait or caller-authored SQL. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; + +use registry_platform_sqlite::{ + schema_fingerprint, CapturedSnapshot, ColumnContract, ColumnType, DatabaseProfile, + InspectionLimits, LiveDatabaseFile, ParameterContract, ReadOnlyStatement, ResultRow, + SchemaBinding, SqliteError, StatementContract, StatementLimits, Value, +}; +use thiserror::Error; +use tokio::sync::{OwnedSemaphorePermit, Semaphore}; + +use crate::auth::RowAuthority; +use crate::contract::{DataType, SourceProfile}; +use crate::model::{CompiledOperation, CompiledRegistry, CompiledResource, OperationKind}; + +const MAXIMUM_CELL_BYTES: usize = 1024 * 1024; +const MAXIMUM_RESPONSE_BYTES: usize = 8 * 1024 * 1024; +const MAXIMUM_STATEMENT_STEPS: u64 = 2_000_000; +const SCHEMA_MAXIMUM_OBJECTS: usize = 10_000; +const SCHEMA_MAXIMUM_SQL_BYTES: usize = 8 * 1024 * 1024; +const SCHEMA_MAXIMUM_STEPS: u64 = 1_000_000; + +#[derive(Clone, Debug)] +pub struct SqliteRuntimeLimits { + pub request_timeout: Duration, + pub concurrent_queries: usize, +} + +#[derive(Clone, Debug)] +pub struct RuntimeSourceBinding { + pub path: PathBuf, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum SourceRevision { + Snapshot(String), + LiveUnversioned, +} + +impl SourceRevision { + #[must_use] + pub fn cursor_value(&self) -> String { + match self { + Self::Snapshot(value) => value.clone(), + Self::LiveUnversioned => "live:unversioned".to_owned(), + } + } +} + +#[derive(Clone, Debug, Default)] +pub struct OperationQuery { + pub filters: BTreeMap, + pub selectors: BTreeMap, + pub record_identifier: Option, + pub row_authority: Option, + pub after_order: Option>, + pub fetch_limit: Option, +} + +#[derive(Clone, Debug)] +pub struct OperationResult { + pub rows: Vec, + pub source_revision: SourceRevision, +} + +#[derive(Debug, Error)] +pub enum SqliteRuntimeError { + #[error("runtime source binding is missing")] + MissingSource, + #[error("compiled source or operation is unknown")] + UnknownOperation, + #[error("source schema does not match the governed contract")] + SchemaMismatch, + #[error("compiled SQLite plan is invalid")] + InvalidPlan, + #[error("SQLite query admission timed out")] + AdmissionTimeout, + #[error("SQLite source operation failed")] + Source(#[from] SqliteError), +} + +struct OperationExecutor { + statement: Arc, + operation: CompiledOperation, + source_revision: SourceRevision, +} + +#[derive(Clone)] +struct ReadinessSource { + profile: DatabaseProfile, + expected_schema_fingerprint: String, +} + +/// Fixed operation inventory over one compiled Registry. +pub struct SqliteRuntime { + operations: BTreeMap, + readiness_sources: Vec, + admission: Arc, + timeout: Duration, +} + +impl SqliteRuntime { + pub fn open( + registry: &CompiledRegistry, + bindings: &BTreeMap, + limits: SqliteRuntimeLimits, + ) -> Result { + if limits.request_timeout.is_zero() || limits.concurrent_queries == 0 { + return Err(SqliteRuntimeError::InvalidPlan); + } + + let mut profiles = BTreeMap::new(); + let mut readiness_sources = Vec::new(); + for source in ®istry.sources { + let binding = bindings + .get(&source.id) + .ok_or(SqliteRuntimeError::MissingSource)?; + let (profile, revision) = match source.profile { + SourceProfile::Snapshot => { + let captured = CapturedSnapshot::capture(&binding.path)?; + let revision = SourceRevision::Snapshot(captured.digest().to_owned()); + (DatabaseProfile::Snapshot(captured), revision) + } + SourceProfile::LiveReadOnly => { + let live = LiveDatabaseFile::bind(&binding.path)?; + ( + DatabaseProfile::LiveReadOnly(live), + SourceRevision::LiveUnversioned, + ) + } + }; + let observed = schema_fingerprint( + &profile, + &InspectionLimits { + maximum_objects: SCHEMA_MAXIMUM_OBJECTS, + maximum_sql_bytes: SCHEMA_MAXIMUM_SQL_BYTES, + maximum_statement_steps: SCHEMA_MAXIMUM_STEPS, + timeout: limits.request_timeout, + }, + )?; + if observed != source.expected_schema_fingerprint { + return Err(SqliteRuntimeError::SchemaMismatch); + } + readiness_sources.push(ReadinessSource { + profile: profile.clone(), + expected_schema_fingerprint: source.expected_schema_fingerprint.clone(), + }); + profiles.insert(source.id.clone(), (profile, revision)); + } + + let mut operations = BTreeMap::new(); + for resource in ®istry.resources { + for operation in &resource.operations { + let (profile, source_revision) = profiles + .get(&operation.query.source) + .ok_or(SqliteRuntimeError::MissingSource)?; + let source = registry + .sources + .iter() + .find(|source| source.id == operation.query.source) + .ok_or(SqliteRuntimeError::MissingSource)?; + let contract = statement_contract( + resource, + operation, + &limits, + &source.expected_schema_fingerprint, + )?; + let statement = ReadOnlyStatement::open(profile.clone(), contract)?; + if operations + .insert( + operation.identifier.clone(), + OperationExecutor { + statement: Arc::new(statement), + operation: operation.clone(), + source_revision: source_revision.clone(), + }, + ) + .is_some() + { + return Err(SqliteRuntimeError::InvalidPlan); + } + } + } + + Ok(Self { + operations, + readiness_sources, + admission: Arc::new(Semaphore::new(limits.concurrent_queries)), + timeout: limits.request_timeout, + }) + } + + /// Confirm every continuing source release gate without reading row data. + /// Failures remain categorical so callers cannot expose a source identifier, + /// path, schema, or value through readiness. + pub async fn is_ready(&self) -> bool { + for source in &self.readiness_sources { + let source = source.clone(); + let timeout = self.timeout; + let check = tokio::task::spawn_blocking(move || { + if let DatabaseProfile::Snapshot(snapshot) = &source.profile { + snapshot.verify_unchanged()?; + } + let observed = schema_fingerprint( + &source.profile, + &InspectionLimits { + maximum_objects: SCHEMA_MAXIMUM_OBJECTS, + maximum_sql_bytes: SCHEMA_MAXIMUM_SQL_BYTES, + maximum_statement_steps: SCHEMA_MAXIMUM_STEPS, + timeout, + }, + )?; + Ok::(observed == source.expected_schema_fingerprint) + }); + if !matches!(check.await, Ok(Ok(true))) { + return false; + } + } + true + } + + #[must_use] + pub fn source_revision(&self, operation: &str) -> Option<&SourceRevision> { + self.operations + .get(operation) + .map(|item| &item.source_revision) + } + + pub async fn execute( + &self, + operation: &str, + query: OperationQuery, + ) -> Result { + let executor = self + .operations + .get(operation) + .ok_or(SqliteRuntimeError::UnknownOperation)?; + let permit = self.acquire().await?; + let values = bind_operation_values(&executor.operation, query)?; + let result = executor.statement.execute(&values).await; + drop(permit); + Ok(OperationResult { + rows: result?.rows, + source_revision: executor.source_revision.clone(), + }) + } + + async fn acquire(&self) -> Result { + tokio::time::timeout(self.timeout, Arc::clone(&self.admission).acquire_owned()) + .await + .map_err(|_| SqliteRuntimeError::AdmissionTimeout)? + .map_err(|_| SqliteRuntimeError::InvalidPlan) + } +} + +fn statement_contract( + resource: &CompiledResource, + operation: &CompiledOperation, + limits: &SqliteRuntimeLimits, + expected_schema_fingerprint: &str, +) -> Result { + let result_columns = result_columns(operation); + let columns = result_columns + .iter() + .map(|column| ColumnContract { + name: column.clone(), + value_type: column_type(resource, column), + }) + .collect::>(); + let mut parameters = Vec::new(); + let sql = match &operation.kind { + OperationKind::List => list_sql(operation, &result_columns, &mut parameters), + OperationKind::Read => read_sql(resource, operation, &result_columns, &mut parameters), + OperationKind::Lookup { .. } => lookup_sql(operation, &result_columns, &mut parameters), + }; + let maximum_rows = match &operation.kind { + OperationKind::List => u64::from( + operation + .query + .pagination + .as_ref() + .ok_or(SqliteRuntimeError::InvalidPlan)? + .maximum_page_size, + ) + .saturating_add(1), + OperationKind::Read | OperationKind::Lookup { .. } => 2, + }; + Ok(StatementContract { + sql, + columns, + parameters, + limits: StatementLimits { + maximum_rows, + maximum_cell_bytes: MAXIMUM_CELL_BYTES, + maximum_response_bytes: MAXIMUM_RESPONSE_BYTES, + maximum_statement_steps: MAXIMUM_STATEMENT_STEPS, + timeout: limits.request_timeout, + // Aggregate process concurrency is owned above. One connection per + // fixed operation prevents connection count from multiplying again. + concurrency: 1, + }, + schema: Some(SchemaBinding { + expected_fingerprint: expected_schema_fingerprint.to_owned(), + maximum_objects: SCHEMA_MAXIMUM_OBJECTS, + maximum_sql_bytes: SCHEMA_MAXIMUM_SQL_BYTES, + }), + }) +} + +fn result_columns(operation: &CompiledOperation) -> Vec { + let mut columns = operation.query.projected_columns.clone(); + for column in &operation.query.order_by { + if !columns.contains(column) { + columns.push(column.clone()); + } + } + columns +} + +fn column_type(resource: &CompiledResource, column: &str) -> ColumnType { + resource + .properties + .iter() + .find(|property| property.source_column == column) + .map(|property| data_type(property.data_type)) + .unwrap_or(ColumnType::String) +} + +fn data_type(value: DataType) -> ColumnType { + match value { + DataType::Boolean => ColumnType::Boolean, + DataType::Integer => ColumnType::Integer, + DataType::String | DataType::Date | DataType::DateTime | DataType::ControlledCode => { + ColumnType::String + } + } +} + +fn list_sql( + operation: &CompiledOperation, + columns: &[String], + parameters: &mut Vec, +) -> String { + let mut predicates = Vec::new(); + for (index, filter) in operation.query.filters.iter().enumerate() { + let present = format!("filter_{index}_present"); + let value = format!("filter_{index}"); + parameters.push(parameter(&present)); + parameters.push(parameter(&value)); + predicates.push(format!( + "(:{present} = 0 OR {} = :{value})", + quote_identifier(&filter.source_column) + )); + } + add_row_authority(operation, parameters, &mut predicates); + parameters.push(parameter("cursor_present")); + let keyset = keyset_predicate(&operation.query.order_by, parameters); + predicates.push(format!("(:cursor_present = 0 OR ({keyset}))")); + parameters.push(parameter("fetch_limit")); + format!( + "SELECT {} FROM {} WHERE {} ORDER BY {} LIMIT :fetch_limit", + select_list(columns), + quote_identifier(&operation.query.view), + predicates.join(" AND "), + operation + .query + .order_by + .iter() + .map(|column| format!("{} ASC", quote_identifier(column))) + .collect::>() + .join(", ") + ) +} + +fn read_sql( + resource: &CompiledResource, + operation: &CompiledOperation, + columns: &[String], + parameters: &mut Vec, +) -> String { + parameters.push(parameter("record_identifier")); + let mut predicates = vec![format!( + "{} = :record_identifier", + quote_identifier(&resource.record_context.record_identifier_column) + )]; + add_row_authority(operation, parameters, &mut predicates); + format!( + "SELECT {} FROM {} WHERE {} LIMIT 2", + select_list(columns), + quote_identifier(&operation.query.view), + predicates.join(" AND ") + ) +} + +fn lookup_sql( + operation: &CompiledOperation, + columns: &[String], + parameters: &mut Vec, +) -> String { + let mut predicates = Vec::new(); + for (index, selector) in operation.query.selectors.iter().enumerate() { + let name = format!("selector_{index}"); + parameters.push(parameter(&name)); + predicates.push(format!( + "{} = :{name}", + quote_identifier(&selector.source_column) + )); + } + add_row_authority(operation, parameters, &mut predicates); + format!( + "SELECT {} FROM {} WHERE {} LIMIT 2", + select_list(columns), + quote_identifier(&operation.query.view), + predicates.join(" AND ") + ) +} + +fn add_row_authority( + operation: &CompiledOperation, + parameters: &mut Vec, + predicates: &mut Vec, +) { + if let crate::model::CompiledAccess::Protected { + row_binding: Some(binding), + .. + } = &operation.access + { + parameters.push(parameter("row_authority")); + predicates.push(format!( + "{} = :row_authority", + quote_identifier(&binding.source_column) + )); + } +} + +fn keyset_predicate(order: &[String], parameters: &mut Vec) -> String { + let mut alternatives = Vec::new(); + for index in 0..order.len() { + let mut terms = Vec::new(); + for (prior, column) in order.iter().take(index).enumerate() { + terms.push(format!("{} = :cursor_{prior}", quote_identifier(column))); + } + terms.push(format!( + "{} > :cursor_{index}", + quote_identifier(&order[index]) + )); + alternatives.push(format!("({})", terms.join(" AND "))); + } + for index in 0..order.len() { + parameters.push(parameter(&format!("cursor_{index}"))); + } + alternatives.join(" OR ") +} + +fn parameter(name: &str) -> ParameterContract { + ParameterContract { + name: name.to_owned(), + required: true, + } +} + +fn select_list(columns: &[String]) -> String { + columns + .iter() + .map(|column| quote_identifier(column)) + .collect::>() + .join(", ") +} + +fn quote_identifier(value: &str) -> String { + format!("\"{}\"", value.replace('"', "\"\"")) +} + +fn bind_operation_values( + operation: &CompiledOperation, + query: OperationQuery, +) -> Result, SqliteRuntimeError> { + let mut values = BTreeMap::new(); + match &operation.kind { + OperationKind::List => { + let declared = operation + .query + .filters + .iter() + .map(|filter| filter.parameter.as_str()) + .collect::>(); + if query + .filters + .keys() + .any(|name| !declared.contains(name.as_str())) + { + return Err(SqliteRuntimeError::InvalidPlan); + } + for (index, filter) in operation.query.filters.iter().enumerate() { + let value = query.filters.get(&filter.parameter).cloned(); + values.insert( + format!("filter_{index}_present"), + Value::Integer(i64::from(value.is_some())), + ); + values.insert(format!("filter_{index}"), value.unwrap_or(Value::Null)); + } + let after = query.after_order.unwrap_or_default(); + if !after.is_empty() && after.len() != operation.query.order_by.len() { + return Err(SqliteRuntimeError::InvalidPlan); + } + values.insert( + "cursor_present".into(), + Value::Integer(i64::from(!after.is_empty())), + ); + for index in 0..operation.query.order_by.len() { + values.insert( + format!("cursor_{index}"), + after.get(index).cloned().unwrap_or(Value::Null), + ); + } + values.insert( + "fetch_limit".into(), + Value::Integer(i64::from( + query.fetch_limit.ok_or(SqliteRuntimeError::InvalidPlan)?, + )), + ); + } + OperationKind::Read => { + values.insert( + "record_identifier".into(), + Value::String( + query + .record_identifier + .ok_or(SqliteRuntimeError::InvalidPlan)?, + ), + ); + } + OperationKind::Lookup { .. } => { + if query.selectors.len() != operation.query.selectors.len() { + return Err(SqliteRuntimeError::InvalidPlan); + } + for (index, selector) in operation.query.selectors.iter().enumerate() { + values.insert( + format!("selector_{index}"), + query + .selectors + .get(&selector.name) + .cloned() + .ok_or(SqliteRuntimeError::InvalidPlan)?, + ); + } + } + } + if let crate::model::CompiledAccess::Protected { + row_binding: Some(binding), + .. + } = &operation.access + { + let row = query.row_authority.ok_or(SqliteRuntimeError::InvalidPlan)?; + if row.source_column != binding.source_column { + return Err(SqliteRuntimeError::InvalidPlan); + } + values.insert("row_authority".into(), Value::String(row.value)); + } else if query.row_authority.is_some() { + return Err(SqliteRuntimeError::InvalidPlan); + } + Ok(values) +} diff --git a/crates/registry-relay-v2/src/startup.rs b/crates/registry-relay-v2/src/startup.rs new file mode 100644 index 000000000..eb78e8a83 --- /dev/null +++ b/crates/registry-relay-v2/src/startup.rs @@ -0,0 +1,1057 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Atomic process startup for the Relay V2 `relay` binary. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::future::IntoFuture; +use std::io::Read as _; +use std::net::SocketAddr; +use std::path::{Component, Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use axum::Router; +use jsonwebtoken::Algorithm; +use registry_platform_audit::{ + AuditChainProfile, AuditSink, ChainState, DurableSegmentedJsonlSink, +}; +use registry_platform_config::{SecretProvider, SecretResolver}; +use registry_platform_oidc::{ + fetch_discovery, JwksFetcher, JwksFetcherConfig, OidcDiscoveryConfig, TokenVerifier, + TokenVerifierConfig, +}; +use thiserror::Error; +use tokio::net::TcpListener; +use url::Url; +use zeroize::Zeroizing; + +use crate::audit::RelayAudit; +use crate::auth::RelayAuthenticator; +use crate::contract::{AccessRule, IssuerRuntime, RegistryContract, RelayRuntime}; +use crate::cursor::CursorKey; +use crate::package::{load_package, VerifiedPackage}; +use crate::server::{ + router, AlignmentMetadata, InstitutionMetadata, QuotaConfig, RelayService, ServiceMetadata, +}; +use crate::source_observation::observe_sources; +use crate::sqlite_runtime::{RuntimeSourceBinding, SqliteRuntime, SqliteRuntimeLimits}; + +const MAXIMUM_RUNTIME_BYTES: u64 = 1024 * 1024; +const MAXIMUM_AUDIT_SEGMENT_BYTES: u64 = 64 * 1024 * 1024; +const DEFAULT_CURSOR_MAXIMUM_AGE: Duration = Duration::from_secs(300); +const DEFAULT_SHUTDOWN_GRACE: Duration = Duration::from_secs(30); +const ISSUER_NETWORK_TIMEOUT: Duration = Duration::from_secs(5); +const MAXIMUM_TOKEN_LIFETIME: Duration = Duration::from_secs(15 * 60); +const TOKEN_CLOCK_LEEWAY: Duration = Duration::from_secs(30); +const DISCOVERY_SUFFIX: &str = "/.well-known/openid-configuration"; +const HEALTHCHECK_TIMEOUT: Duration = Duration::from_secs(5); +const MAXIMUM_HEALTH_BODY_BYTES: usize = 128; +const HEALTH_BODY: &[u8] = br#"{"status":"ok"}"#; + +#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)] +pub enum StartupError { + #[error("the runtime configuration could not be loaded")] + RuntimeLoad, + #[error("the runtime configuration is invalid")] + RuntimeInvalid, + #[error("the sealed package could not be verified")] + PackageInvalid, + #[error("a runtime source could not be verified")] + SourceInvalid, + #[error("the configured issuer is not ready")] + IssuerUnavailable, + #[error("the required audit sink is not ready")] + AuditUnavailable, + #[error("a required secret is unavailable")] + SecretUnavailable, + #[error("the cursor configuration is invalid")] + CursorInvalid, + #[error("the service did not become ready")] + NotReady, + #[error("the listener could not be started")] + Listener, + #[error("the graceful shutdown deadline elapsed")] + ShutdownTimeout, + #[error("the healthcheck failed")] + Healthcheck, +} + +/// Fully initialized immutable service state. Constructing this value performs +/// every fallible readiness step except taking the listener socket. +pub struct PreparedRelay { + bind: SocketAddr, + service: Arc, + app: Router, + shutdown_grace: Duration, +} + +/// Verify one runtime and construct its immutable service without listening. +pub async fn prepare(runtime_path: &Path) -> Result { + let (runtime_root, runtime) = load_runtime(runtime_path)?; + let paths = RuntimePaths::resolve(&runtime_root, &runtime)?; + + // The package is the governed trust root. Verify it before opening issuer, + // audit, source, or listener resources. + let package = load_package(&paths.package).map_err(|_| StartupError::PackageInvalid)?; + validate_runtime_contract(&runtime, &package.contract)?; + + let observed = observe_sources(&runtime_root, &package.contract, &runtime) + .map_err(|_| StartupError::SourceInvalid)?; + if observed.len() != package.contract.sources.len() { + return Err(StartupError::SourceInvalid); + } + require_packaged_source_schemas(&package, &observed)?; + + let request_timeout = Duration::from_millis(runtime.limits.request_timeout_milliseconds); + let sqlite = Arc::new( + SqliteRuntime::open( + &package.registry, + &paths.sources, + SqliteRuntimeLimits { + request_timeout, + concurrent_queries: usize::try_from(runtime.limits.concurrent_queries) + .map_err(|_| StartupError::RuntimeInvalid)?, + }, + ) + .map_err(|_| StartupError::SourceInvalid)?, + ); + + let authenticator = build_authenticator(runtime.authentication.issuer.as_ref()).await?; + let audit = build_audit( + &runtime_root, + &runtime.audit.integrity_key_ref, + &paths.audit, + ) + .await?; + let (cursor_key, cursor_maximum_age) = build_cursor(&runtime_root, &runtime)?; + let quota = runtime.quotas.as_ref().map(|quota| QuotaConfig { + requests_per_minute: quota.requests_per_minute, + burst: quota.burst, + }); + let metadata = service_metadata(&package.contract); + let service = Arc::new(RelayService::new( + Arc::new(package.registry), + Arc::new(package.artifacts), + sqlite, + authenticator, + audit, + cursor_key, + cursor_maximum_age, + request_timeout, + quota, + metadata, + )); + if !service.is_ready().await { + return Err(StartupError::NotReady); + } + let bind = runtime + .server + .bind + .parse() + .map_err(|_| StartupError::RuntimeInvalid)?; + let shutdown_grace = runtime + .shutdown + .as_ref() + .map_or(DEFAULT_SHUTDOWN_GRACE, |item| { + Duration::from_millis(item.grace_period_milliseconds) + }); + Ok(PreparedRelay { + bind, + app: router(Arc::clone(&service)), + service, + shutdown_grace, + }) +} + +/// Prepare atomically, bind only after readiness, and serve until SIGINT or +/// SIGTERM. Configuration and package state never reload in place. +pub async fn serve(runtime_path: &Path) -> Result<(), StartupError> { + tracing::info!(target: "registry_relay_v2::startup", "relay startup began"); + let prepared = prepare(runtime_path).await?; + if !prepared.service.is_ready().await { + return Err(StartupError::NotReady); + } + let listener = TcpListener::bind(prepared.bind) + .await + .map_err(|_| StartupError::Listener)?; + let address = listener.local_addr().map_err(|_| StartupError::Listener)?; + tracing::info!( + target: "registry_relay_v2::startup", + bind = %address, + "relay service listening" + ); + serve_listener(listener, prepared.app, prepared.shutdown_grace).await +} + +async fn serve_listener( + listener: TcpListener, + app: Router, + shutdown_grace: Duration, +) -> Result<(), StartupError> { + let (shutdown_tx, mut shutdown_rx) = tokio::sync::watch::channel(false); + let server = axum::serve(listener, app) + .with_graceful_shutdown(async move { + while !*shutdown_rx.borrow_and_update() { + if shutdown_rx.changed().await.is_err() { + break; + } + } + }) + .into_future(); + tokio::pin!(server); + let result = tokio::select! { + result = &mut server => result.map_err(|_| StartupError::Listener), + () = shutdown_signal() => { + tracing::info!(target: "registry_relay_v2::startup", "relay shutdown began"); + let _ = shutdown_tx.send(true); + tokio::time::timeout(shutdown_grace, &mut server) + .await + .map_err(|_| StartupError::ShutdownTimeout)? + .map_err(|_| StartupError::Listener) + } + }; + if result.is_ok() { + tracing::info!(target: "registry_relay_v2::startup", "relay shutdown complete"); + } + result +} + +/// Probe exactly the minimal unauthenticated liveness response. +pub async fn healthcheck(raw_url: &str) -> Result<(), StartupError> { + let url = Url::parse(raw_url).map_err(|_| StartupError::Healthcheck)?; + if !matches!(url.scheme(), "http" | "https") + || url.host_str().is_none() + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + { + return Err(StartupError::Healthcheck); + } + let client = reqwest::Client::builder() + .timeout(HEALTHCHECK_TIMEOUT) + .redirect(reqwest::redirect::Policy::none()) + // A process-local health probe must not leak its URL or response to an + // ambient proxy configured for unrelated outbound traffic. + .no_proxy() + .build() + .map_err(|_| StartupError::Healthcheck)?; + let mut response = client + .get(url) + .send() + .await + .map_err(|_| StartupError::Healthcheck)?; + if response.status() != reqwest::StatusCode::OK + || !response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.eq_ignore_ascii_case("application/json")) + { + return Err(StartupError::Healthcheck); + } + let mut body = Vec::new(); + while let Some(chunk) = response + .chunk() + .await + .map_err(|_| StartupError::Healthcheck)? + { + if body.len().saturating_add(chunk.len()) > MAXIMUM_HEALTH_BODY_BYTES { + return Err(StartupError::Healthcheck); + } + body.extend_from_slice(&chunk); + } + if body != HEALTH_BODY { + return Err(StartupError::Healthcheck); + } + Ok(()) +} + +fn load_runtime(path: &Path) -> Result<(PathBuf, RelayRuntime), StartupError> { + let path_metadata = validate_runtime_path(path)?; + let mut file = fs::File::open(path).map_err(|_| StartupError::RuntimeLoad)?; + let opened_metadata = file.metadata().map_err(|_| StartupError::RuntimeLoad)?; + if !opened_metadata.is_file() + || opened_metadata.len() == 0 + || opened_metadata.len() > MAXIMUM_RUNTIME_BYTES + || !same_file(&path_metadata, &opened_metadata) + || !safe_runtime_permissions(&opened_metadata) + { + return Err(StartupError::RuntimeInvalid); + } + let mut bytes = Vec::with_capacity(opened_metadata.len() as usize); + file.by_ref() + .take(MAXIMUM_RUNTIME_BYTES.saturating_add(1)) + .read_to_end(&mut bytes) + .map_err(|_| StartupError::RuntimeLoad)?; + let final_metadata = validate_runtime_path(path)?; + if bytes.len() as u64 > MAXIMUM_RUNTIME_BYTES || !same_file(&final_metadata, &opened_metadata) { + return Err(StartupError::RuntimeInvalid); + } + let yaml = std::str::from_utf8(&bytes).map_err(|_| StartupError::RuntimeInvalid)?; + let runtime = RelayRuntime::parse_yaml(yaml).map_err(|_| StartupError::RuntimeInvalid)?; + let parent = path + .parent() + .filter(|value| !value.as_os_str().is_empty()) + .unwrap_or(Path::new(".")); + let root = parent + .canonicalize() + .map_err(|_| StartupError::RuntimeLoad)?; + Ok((root, runtime)) +} + +#[cfg(unix)] +fn validate_runtime_path(path: &Path) -> Result { + use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _}; + + let absolute = if path.is_absolute() { + path.to_owned() + } else { + std::env::current_dir() + .map_err(|_| StartupError::RuntimeLoad)? + .join(path) + }; + let effective_user = rustix::process::geteuid().as_raw(); + let component_count = absolute.components().count(); + let mut current = PathBuf::new(); + let mut final_metadata = None; + for (index, component) in absolute.components().enumerate() { + current.push(component.as_os_str()); + let metadata = fs::symlink_metadata(¤t).map_err(|_| StartupError::RuntimeLoad)?; + let final_component = index + 1 == component_count; + if metadata.file_type().is_symlink() + || if final_component { + !metadata.is_file() + || !trusted_unix_owner_and_mode( + metadata.uid(), + metadata.permissions().mode(), + effective_user, + false, + ) + } else { + !metadata.is_dir() + || !trusted_unix_owner_and_mode( + metadata.uid(), + metadata.permissions().mode(), + effective_user, + true, + ) + } + { + return Err(StartupError::RuntimeInvalid); + } + if final_component { + final_metadata = Some(metadata); + } + } + final_metadata.ok_or(StartupError::RuntimeInvalid) +} + +#[cfg(unix)] +fn trusted_unix_owner_and_mode( + owner: u32, + mode: u32, + effective_user: u32, + allow_root_sticky: bool, +) -> bool { + let trusted_owner = owner == 0 || owner == effective_user; + let not_writable_by_others = mode & 0o022 == 0; + let protected_shared_ancestor = allow_root_sticky && owner == 0 && mode & 0o1000 != 0; + trusted_owner && (not_writable_by_others || protected_shared_ancestor) +} + +#[cfg(not(unix))] +fn validate_runtime_path(_path: &Path) -> Result { + // This trust contract depends on Unix ownership and sticky-directory + // semantics. Platforms without an equivalent implementation fail closed. + Err(StartupError::RuntimeInvalid) +} + +#[cfg(unix)] +fn same_file(path_metadata: &fs::Metadata, opened_metadata: &fs::Metadata) -> bool { + use std::os::unix::fs::MetadataExt as _; + + path_metadata.dev() == opened_metadata.dev() && path_metadata.ino() == opened_metadata.ino() +} + +#[cfg(unix)] +fn safe_runtime_permissions(metadata: &fs::Metadata) -> bool { + use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _}; + + trusted_unix_owner_and_mode( + metadata.uid(), + metadata.permissions().mode(), + rustix::process::geteuid().as_raw(), + false, + ) +} + +#[cfg(not(unix))] +fn same_file(path_metadata: &fs::Metadata, opened_metadata: &fs::Metadata) -> bool { + path_metadata.len() == opened_metadata.len() + && path_metadata.modified().ok() == opened_metadata.modified().ok() +} + +#[cfg(not(unix))] +fn safe_runtime_permissions(_metadata: &fs::Metadata) -> bool { + false +} + +struct RuntimePaths { + package: PathBuf, + sources: BTreeMap, + audit: PathBuf, +} + +impl RuntimePaths { + fn resolve(root: &Path, runtime: &RelayRuntime) -> Result { + let package = resolve_binding(root, &runtime.package_path)?; + reject_existing_symlink_components(&package)?; + let mut sources = BTreeMap::new(); + for (identifier, source) in runtime.sources.iter() { + let path = resolve_binding(root, &source.path)?; + reject_existing_symlink_components(&path)?; + sources.insert(identifier.to_owned(), RuntimeSourceBinding { path }); + } + let audit = resolve_binding(root, &runtime.audit.sink)?; + reject_existing_symlink_components(&audit)?; + Ok(Self { + package, + sources, + audit, + }) + } +} + +fn resolve_binding(root: &Path, value: &str) -> Result { + let path = Path::new(value); + if path.as_os_str().is_empty() { + return Err(StartupError::RuntimeInvalid); + } + if path.is_absolute() { + if path.components().any(|component| { + !matches!( + component, + Component::RootDir | Component::Prefix(_) | Component::Normal(_) + ) + }) { + return Err(StartupError::RuntimeInvalid); + } + Ok(path.to_owned()) + } else { + if path + .components() + .any(|component| !matches!(component, Component::Normal(_))) + { + return Err(StartupError::RuntimeInvalid); + } + Ok(root.join(path)) + } +} + +fn reject_existing_symlink_components(target: &Path) -> Result<(), StartupError> { + let mut current = PathBuf::new(); + for component in target.components() { + current.push(component.as_os_str()); + match fs::symlink_metadata(¤t) { + Ok(metadata) if metadata.file_type().is_symlink() => { + return Err(StartupError::RuntimeInvalid); + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => break, + Err(_) => return Err(StartupError::RuntimeInvalid), + } + } + Ok(()) +} + +fn validate_runtime_contract( + runtime: &RelayRuntime, + contract: &RegistryContract, +) -> Result<(), StartupError> { + let governed = contract.sources.keys().collect::>(); + let bound = runtime.sources.keys().collect::>(); + if governed != bound { + return Err(StartupError::RuntimeInvalid); + } + let has_list = contract + .resources + .iter() + .any(|resource| resource.operations.list.is_some()); + if has_list && runtime.cursor.is_none() { + return Err(StartupError::CursorInvalid); + } + let protected = contract.resources.iter().any(|resource| { + resource + .operations + .list + .iter() + .map(|operation| &operation.access) + .chain( + resource + .operations + .read + .iter() + .map(|operation| &operation.access), + ) + .chain( + resource + .operations + .lookups + .iter() + .map(|operation| &operation.access), + ) + .any(|access| matches!(access, AccessRule::Protected(_))) + }); + if protected && runtime.authentication.issuer.is_none() { + return Err(StartupError::IssuerUnavailable); + } + let has_lookup = contract + .resources + .iter() + .any(|resource| !resource.operations.lookups.is_empty()); + if has_lookup && runtime.quotas.is_none() { + return Err(StartupError::RuntimeInvalid); + } + Ok(()) +} + +fn require_packaged_source_schemas( + package: &VerifiedPackage, + observed: &[crate::model::ObservedSourceSchema], +) -> Result<(), StartupError> { + let observed = observed + .iter() + .map(|schema| (schema.source.clone(), schema.clone())) + .collect::>(); + if observed != package.manifest.source_schemas { + return Err(StartupError::SourceInvalid); + } + Ok(()) +} + +async fn build_authenticator( + issuer: Option<&IssuerRuntime>, +) -> Result, StartupError> { + let Some(issuer) = issuer else { + return Ok(None); + }; + if issuer.algorithms.len() != 1 || issuer.token_types.as_slice() != ["at+jwt"] { + return Err(StartupError::RuntimeInvalid); + } + let (issuer_identifier, discovery_url) = parse_discovery_url(&issuer.discovery_url)?; + let discovery = fetch_discovery(&OidcDiscoveryConfig { + issuer: issuer_identifier.clone(), + jwks_uri_override: None, + discovery_timeout: ISSUER_NETWORK_TIMEOUT, + max_doc_bytes: 1024 * 1024, + }) + .await + .map_err(|_| StartupError::IssuerUnavailable)?; + if discovery.issuer != issuer_identifier || discovery_url != issuer.discovery_url { + return Err(StartupError::IssuerUnavailable); + } + let fetcher = Arc::new(JwksFetcher::new( + discovery.jwks_uri, + JwksFetcherConfig { + request_timeout: ISSUER_NETWORK_TIMEOUT, + ..JwksFetcherConfig::defaults() + }, + )); + fetcher + .ensure_key_set() + .await + .map_err(|_| StartupError::IssuerUnavailable)?; + let algorithm = match issuer.algorithms[0].as_str() { + "EdDSA" => Algorithm::EdDSA, + "ES256" => Algorithm::ES256, + "RS256" => Algorithm::RS256, + _ => return Err(StartupError::RuntimeInvalid), + }; + let verifier = TokenVerifier::new( + TokenVerifierConfig::registry_relay_access_profile( + issuer_identifier, + vec![issuer.audience.clone()], + vec![algorithm], + issuer.token_types.clone(), + ) + .with_max_token_lifetime(Some(MAXIMUM_TOKEN_LIFETIME)) + .with_leeway(TOKEN_CLOCK_LEEWAY), + fetcher, + ); + Ok(Some(RelayAuthenticator::new( + Arc::new(verifier), + issuer.audience.clone(), + TOKEN_CLOCK_LEEWAY, + ))) +} + +fn parse_discovery_url(value: &str) -> Result<(String, String), StartupError> { + let url = Url::parse(value).map_err(|_| StartupError::RuntimeInvalid)?; + if url.scheme() != "https" + || url.host_str().is_none() + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + || !url.path().ends_with(DISCOVERY_SUFFIX) + { + return Err(StartupError::RuntimeInvalid); + } + let canonical = url.to_string(); + let issuer = canonical + .strip_suffix(DISCOVERY_SUFFIX) + .filter(|value| !value.is_empty()) + .ok_or(StartupError::RuntimeInvalid)? + .to_owned(); + Ok((issuer, canonical)) +} + +async fn build_audit( + runtime_root: &Path, + reference: &str, + path: &Path, +) -> Result { + let secret = resolve_secret(runtime_root, reference)?; + let hasher = AuditChainProfile::production_from_secret_bytes(Zeroizing::new( + secret.expose_secret().to_vec(), + )) + .map_err(|_| StartupError::SecretUnavailable)?; + let hasher = hasher.hasher(); + let sink = Arc::new( + DurableSegmentedJsonlSink::open(path, MAXIMUM_AUDIT_SEGMENT_BYTES) + .map_err(|_| StartupError::AuditUnavailable)?, + ); + let chain = Arc::new( + ChainState::bootstrap_or_start_empty(sink.as_ref(), hasher.clone()) + .await + .map_err(|_| StartupError::AuditUnavailable)?, + ); + let probe_sink = Arc::clone(&sink); + let probe_hasher = hasher.clone(); + let sink_for_events: Arc = sink; + Ok( + RelayAudit::new(chain, sink_for_events).with_readiness_check(move || { + let sink = Arc::clone(&probe_sink); + let hasher = probe_hasher.clone(); + async move { sink.tail_hash_with_hasher(&hasher).await.is_ok() && sink.ready().await } + }), + ) +} + +fn build_cursor( + runtime_root: &Path, + runtime: &RelayRuntime, +) -> Result<(Option>, Duration), StartupError> { + let Some(cursor) = &runtime.cursor else { + return Ok((None, DEFAULT_CURSOR_MAXIMUM_AGE)); + }; + let secret = resolve_secret(runtime_root, &cursor.integrity_key_ref)?; + let key = + CursorKey::new(secret.expose_secret().to_vec()).map_err(|_| StartupError::CursorInvalid)?; + Ok(( + Some(Arc::new(key)), + Duration::from_secs(cursor.maximum_age_seconds), + )) +} + +fn resolve_secret( + runtime_root: &Path, + reference: &str, +) -> Result { + let resolver = SecretResolver::new( + [SecretProvider::Environment, SecretProvider::File], + runtime_root, + ) + .map_err(|_| StartupError::RuntimeInvalid)?; + resolver + .resolve(reference) + .map_err(|_| StartupError::SecretUnavailable) +} + +fn service_metadata(contract: &RegistryContract) -> ServiceMetadata { + ServiceMetadata { + authority: InstitutionMetadata { + identifier: contract.registry.authority.identifier.clone(), + name: contract.registry.authority.name.clone(), + }, + operator: contract + .registry + .operator + .as_ref() + .map(|operator| InstitutionMetadata { + identifier: operator.identifier.clone(), + name: operator.name.clone(), + }), + authoritative_scope: contract.registry.authoritative_scope.clone(), + alignment_targets: contract + .registry + .alignment_targets + .iter() + .map(|target| AlignmentMetadata { + name: target.name.clone(), + version: target.version.clone(), + status: target.status.clone(), + cfr_target: target.cfr_target.clone(), + }) + .collect(), + } +} + +async fn shutdown_signal() { + let interrupt = async { + let _ = tokio::signal::ctrl_c().await; + }; + #[cfg(unix)] + let terminate = async { + match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) { + Ok(mut signal) => { + signal.recv().await; + } + Err(_) => std::future::pending::<()>().await, + } + }; + #[cfg(not(unix))] + let terminate = std::future::pending::<()>(); + tokio::select! { + () = interrupt => {} + () = terminate => {} + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write as _; + use tokio::io::AsyncWriteExt as _; + + #[test] + fn environment_and_owner_only_file_secrets_use_the_closed_resolver() { + const VARIABLE: &str = "RELAY_V2_SECRET_RESOLVER_TEST"; + std::env::set_var(VARIABLE, "synthetic-test-key-material-32-bytes-long"); + let temporary = tempfile::tempdir().expect("temporary root"); + assert_eq!( + resolve_secret(temporary.path(), &format!("secret:env/{VARIABLE}")) + .expect("environment secret") + .expose_secret(), + b"synthetic-test-key-material-32-bytes-long" + ); + + let path = temporary.path().join("audit-integrity-key"); + fs::write(&path, b"synthetic-file-key-material-32-bytes-long").expect("secret writes"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)) + .expect("secret becomes owner-only"); + } + assert_eq!( + resolve_secret(temporary.path(), "secret:file/audit-integrity-key") + .expect("file secret") + .expose_secret(), + b"synthetic-file-key-material-32-bytes-long" + ); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + fs::set_permissions(&path, fs::Permissions::from_mode(0o640)) + .expect("secret becomes unsafe"); + assert!(resolve_secret(temporary.path(), "secret:file/audit-integrity-key").is_err()); + } + } + + #[test] + fn configured_paths_may_be_secure_absolute_bindings_but_cannot_escape() { + let root = Path::new("/srv/relay"); + assert_eq!( + resolve_binding(root, "package").expect("relative path"), + Path::new("/srv/relay/package") + ); + assert_eq!( + resolve_binding(root, "/var/lib/relay/audit/events.jsonl").expect("absolute path"), + Path::new("/var/lib/relay/audit/events.jsonl") + ); + assert!(resolve_binding(root, "../package").is_err()); + assert_eq!( + resolve_binding(root, "var/./audit.jsonl").expect("normalized relative path"), + Path::new("/srv/relay/var/audit.jsonl") + ); + assert!(resolve_binding(root, "/var/lib/relay/../package").is_err()); + } + + #[test] + fn issuer_discovery_is_one_exact_https_profile() { + assert_eq!( + parse_discovery_url( + "https://identity.example.invalid/.well-known/openid-configuration" + ) + .expect("valid discovery") + .0, + "https://identity.example.invalid" + ); + assert!(parse_discovery_url( + "http://identity.example.invalid/.well-known/openid-configuration" + ) + .is_err()); + assert!(parse_discovery_url( + "https://identity.example.invalid/.well-known/oauth-authorization-server" + ) + .is_err()); + assert!(parse_discovery_url( + "https://identity.example.invalid/.well-known/openid-configuration?tenant=x" + ) + .is_err()); + } + + #[cfg(unix)] + #[test] + fn a_symlinked_runtime_binding_is_rejected() { + use std::os::unix::fs::symlink; + + let temporary = tempfile::tempdir().expect("temporary root"); + let real = temporary.path().join("real"); + fs::create_dir(&real).expect("real directory"); + let linked = temporary.path().join("linked"); + symlink(&real, &linked).expect("symlink"); + assert!(reject_existing_symlink_components(&linked.join("file")).is_err()); + } + + #[cfg(unix)] + #[test] + fn runtime_trust_rejects_foreign_owners_and_limits_the_sticky_exception() { + let effective_user = 1000; + assert!(trusted_unix_owner_and_mode( + effective_user, + 0o100600, + effective_user, + false + )); + assert!(trusted_unix_owner_and_mode( + 0, + 0o100644, + effective_user, + false + )); + assert!(!trusted_unix_owner_and_mode( + effective_user + 1, + 0o100600, + effective_user, + false + )); + assert!(trusted_unix_owner_and_mode( + 0, + 0o041777, + effective_user, + true + )); + assert!(!trusted_unix_owner_and_mode( + 0, + 0o041777, + effective_user, + false + )); + assert!(!trusted_unix_owner_and_mode( + effective_user, + 0o041777, + effective_user, + true + )); + } + + #[cfg(unix)] + #[test] + fn a_runtime_below_a_writable_ancestor_is_rejected() { + use std::os::unix::fs::PermissionsExt as _; + + let temporary = tempfile::tempdir().expect("temporary root"); + let root = temporary.path().canonicalize().expect("canonical root"); + let writable = root.join("writable"); + fs::create_dir(&writable).expect("writable ancestor"); + fs::set_permissions(&writable, fs::Permissions::from_mode(0o777)) + .expect("ancestor becomes unsafe"); + let runtime = writable.join("runtime.yaml"); + fs::write(&runtime, b"runtime").expect("runtime fixture"); + + assert_eq!( + validate_runtime_path(&runtime).err(), + Some(StartupError::RuntimeInvalid) + ); + } + + #[tokio::test] + async fn healthcheck_requires_the_exact_minimal_response() { + async fn probe(body: &'static [u8]) -> Result<(), StartupError> { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("listener"); + let address = listener.local_addr().expect("address"); + let task = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.expect("connection"); + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n", + body.len() + ); + stream + .write_all(response.as_bytes()) + .await + .expect("headers"); + stream.write_all(body).await.expect("body"); + stream.shutdown().await.expect("finish response"); + }); + let result = healthcheck(&format!("http://{address}/health")).await; + task.await.expect("server task"); + result + } + + assert!(probe(HEALTH_BODY).await.is_ok()); + assert!(probe(br#"{"status":"ok","source":"hidden"}"#) + .await + .is_err()); + } + + #[tokio::test] + async fn audit_path_replacement_revokes_readiness() { + const VARIABLE: &str = "RELAY_V2_STARTUP_TEST_AUDIT_KEY"; + std::env::set_var(VARIABLE, "synthetic-test-key-material-32-bytes-long"); + let temporary = tempfile::tempdir().expect("temporary root"); + let path = temporary.path().join("audit").join("events.jsonl"); + let audit = build_audit(temporary.path(), &format!("secret:env/{VARIABLE}"), &path) + .await + .expect("audit initializes"); + assert!(audit.ready().await); + + fs::remove_file(&path).expect("remove temporary active file"); + assert!(!audit.ready().await); + } + + #[tokio::test] + async fn failed_preparation_never_takes_the_listener() { + let reservation = TcpListener::bind("127.0.0.1:0") + .await + .expect("reserve address"); + let address = reservation.local_addr().expect("reserved address"); + drop(reservation); + + let temporary = tempfile::tempdir().expect("temporary root"); + let path = temporary + .path() + .canonicalize() + .expect("canonical temporary root") + .join("runtime.yaml"); + fs::write( + &path, + format!( + "apiVersion: relay.registrystack.org/v2alpha1\nkind: RelayRuntime\nserver: {{bind: '{address}'}}\npackagePath: missing-package\nsources: {{db: {{path: source.sqlite}}}}\nauthentication: {{issuer: null}}\naudit: {{sink: var/audit.jsonl, integrityKeyRef: secret:env/KEY}}\nlimits: {{requestTimeoutMilliseconds: 1000, concurrentQueries: 1}}\n" + ), + ) + .expect("write runtime"); + + assert_eq!( + prepare(&path).await.err(), + Some(StartupError::PackageInvalid) + ); + let listener = TcpListener::bind(address) + .await + .expect("startup did not bind before readiness"); + drop(listener); + } + + #[test] + fn protected_contracts_require_issuer_lists_require_cursor_and_lookups_require_quota() { + fn contract(operations: &str) -> RegistryContract { + let yaml = r#" +apiVersion: relay.registrystack.org/v2alpha1 +kind: RegistryContract +metadata: {id: records, version: v1, title: Records} +registry: + registryIdentifier: urn:example:registry + name: Example Registry + authority: {identifier: urn:example:authority, name: Example Authority} + authoritativeScope: Example records + baseUri: https://registry.example.invalid/ + identifierLifecyclePolicyRef: governance/identifiers.yaml + alignmentTargets: [] +governance: {controller: urn:example:authority, publisher: urn:example:authority, auditOwner: urn:example:audit} +semantics: {localVocabulary: https://registry.example.invalid/vocabulary/} +classifications: + privacy: {scheme: urn:example:privacy, version: "1"} + institutional: {scheme: urn:example:institutional, version: "1"} + handling: {scheme: urn:example:handling, version: "1"} + provenanceRef: governance/review.yaml +sources: + records: {kind: sqlite, profile: snapshot, expectedSchemaFingerprint: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"} +resources: + - id: record + title: Record + description: Reviewed record + semanticClass: local:Record + source: {source: records, view: records} + classificationDefaults: {privacy: public, institutional: public, handling: public, status: reviewed} + recordContext: + recordIdentifier: {sourceColumn: id} + revisionIdentifier: {sourceColumn: revision} + lifecycleState: {sourceColumn: state, codelist: codelists/states.yaml} + recordedAt: {sourceColumn: recorded_at} + properties: + label: {label: Label, description: Label, sourceColumn: label, type: string, sourceRequired: true, semanticTerm: local:label} + disclosureProfiles: {default: {properties: [label]}} + operations: OPERATIONS +metadataVisibility: {service: public, resources: public, semantics: public, classifications: operator-only, processing: operator-only} +"# + .replace("OPERATIONS", operations); + RegistryContract::parse_yaml(&yaml).expect("generic contract") + } + + let protected = + contract("{read: {access: {scope: registry:record:read}, disclosureProfile: default}}"); + let protected_runtime = RelayRuntime::parse_yaml( + "apiVersion: relay.registrystack.org/v2alpha1\nkind: RelayRuntime\nserver: {bind: '127.0.0.1:18081'}\npackagePath: package\nsources: {records: {path: fixture.sqlite}}\nauthentication: {issuer: null}\naudit: {sink: var/audit.jsonl, integrityKeyRef: secret:env/KEY}\nlimits: {requestTimeoutMilliseconds: 1000, concurrentQueries: 1}\n", + ) + .expect("closed runtime"); + assert_eq!( + validate_runtime_contract(&protected_runtime, &protected), + Err(StartupError::IssuerUnavailable) + ); + + let list = contract( + "{list: {access: public, disclosureProfile: default, filters: [], allowUnfiltered: true, orderBy: [id], pagination: {defaultPageSize: 10, maximumPageSize: 20}}}", + ); + let list_runtime = RelayRuntime::parse_yaml( + "apiVersion: relay.registrystack.org/v2alpha1\nkind: RelayRuntime\nserver: {bind: '127.0.0.1:18082'}\npackagePath: package\nsources: {records: {path: fixture.sqlite}}\nauthentication: {issuer: null}\naudit: {sink: var/audit.jsonl, integrityKeyRef: secret:env/KEY}\nlimits: {requestTimeoutMilliseconds: 1000, concurrentQueries: 1}\n", + ) + .expect("closed runtime"); + assert_eq!( + validate_runtime_contract(&list_runtime, &list), + Err(StartupError::CursorInvalid) + ); + + let lookup = contract( + "{lookups: [{id: by-label, access: public, requestBody: {maximumBytes: 128, selectors: {label: {sourceColumn: label, type: string, minimumBytes: 1, maximumBytes: 32}}}, disclosureProfile: default}]}", + ); + let mut lookup_runtime = RelayRuntime::parse_yaml( + "apiVersion: relay.registrystack.org/v2alpha1\nkind: RelayRuntime\nserver: {bind: '127.0.0.1:18083'}\npackagePath: package\nsources: {records: {path: fixture.sqlite}}\nauthentication: {issuer: null}\naudit: {sink: var/audit.jsonl, integrityKeyRef: secret:env/KEY}\nlimits: {requestTimeoutMilliseconds: 1000, concurrentQueries: 1}\n", + ) + .expect("closed runtime"); + assert_eq!( + validate_runtime_contract(&lookup_runtime, &lookup), + Err(StartupError::RuntimeInvalid) + ); + lookup_runtime.quotas = Some(crate::contract::QuotaRuntime { + requests_per_minute: 60, + burst: 10, + }); + assert_eq!(validate_runtime_contract(&lookup_runtime, &lookup), Ok(())); + } + + #[test] + fn a_runtime_file_is_bounded_and_strict() { + let temporary = tempfile::tempdir().expect("temporary root"); + let path = temporary.path().join("runtime.yaml"); + let mut file = fs::File::create(&path).expect("runtime file"); + writeln!( + file, + "apiVersion: relay.registrystack.org/v2alpha1\nkind: RelayRuntime\nserver: {{bind: '127.0.0.1:0'}}\npackagePath: package\nsources: {{db: {{path: source.sqlite}}}}\nauthentication: {{issuer: null}}\naudit: {{sink: var/audit.jsonl, integrityKeyRef: secret:env/KEY}}\nlimits: {{requestTimeoutMilliseconds: 1000, concurrentQueries: 1}}\nunknown: true" + ) + .expect("write runtime"); + assert_eq!(load_runtime(&path), Err(StartupError::RuntimeInvalid)); + } +} diff --git a/crates/registry-relay-v2/src/tooling.rs b/crates/registry-relay-v2/src/tooling.rs new file mode 100644 index 000000000..85f474ae8 --- /dev/null +++ b/crates/registry-relay-v2/src/tooling.rs @@ -0,0 +1,1115 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Shared authoring facade used verbatim by `relayctl`. + +use std::collections::BTreeSet; +use std::fs; +use std::path::{Component, Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use registry_platform_audit::{ChainState, JsonlFileSink}; +use registry_platform_sqlite::{ + inspect_schema as inspect_sqlite_schema, materialize_fixture, CapturedSnapshot, + DatabaseProfile, SchemaObjectKind, +}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::artifacts::{generate_artifacts, ArtifactSet}; +use crate::audit::RelayAudit; +use crate::compiler::{compile_contract_with_governed_files, GovernedFileSet}; +use crate::contract::{RegistryContract, RelayRuntime}; +use crate::cursor::CursorKey; +use crate::diff::{diff_registries, ChangeImpactReport}; +use crate::fixtures::{ + execute_fixture_journey, fixture_authenticator, parse_journey, FixturePlanReport, +}; +use crate::model::{ + CompileProfile, CompileReport, CompiledRegistry, Diagnostic, DiagnosticSeverity, +}; +use crate::package::{build_package, PackageManifest}; +use crate::server::{ + router, AlignmentMetadata, InstitutionMetadata, QuotaConfig, RelayService, ServiceMetadata, +}; +use crate::source_observation::{inspection_limits, observe_sources}; +use crate::sqlite_runtime::{RuntimeSourceBinding, SqliteRuntime, SqliteRuntimeLimits}; + +#[derive(Clone, Debug)] +pub struct InitOptions { + pub project_root: PathBuf, +} + +#[derive(Clone, Debug)] +pub struct InspectOptions { + pub database_path: PathBuf, + pub starter_output: Option, +} + +#[derive(Clone, Debug)] +pub struct CheckOptions { + pub project_root: PathBuf, + pub production: bool, +} + +#[derive(Clone, Debug)] +pub struct GenerateOptions { + pub project_root: PathBuf, + pub output_dir: Option, +} + +#[derive(Clone, Debug)] +pub struct TestOptions { + pub project_root: PathBuf, + pub fixture_id: Option, +} + +#[derive(Clone, Debug)] +pub struct DiffOptions { + pub previous_root: PathBuf, + pub current_root: PathBuf, +} + +#[derive(Clone, Debug)] +pub struct PackageOptions { + pub project_root: PathBuf, + pub output_dir: PathBuf, +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum ToolingStatus { + Success, + Refused, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ToolingReport { + pub status: ToolingStatus, + pub diagnostics: Vec, + pub details: ToolingDetails, +} + +impl ToolingReport { + pub fn is_success(&self) -> bool { + self.status == ToolingStatus::Success + } + + fn success(details: ToolingDetails) -> Self { + Self { + status: ToolingStatus::Success, + diagnostics: Vec::new(), + details, + } + } + + fn refused(diagnostics: Vec, details: ToolingDetails) -> Self { + Self { + status: ToolingStatus::Refused, + diagnostics, + details, + } + } +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "kebab-case")] +pub enum ToolingDetails { + Initialized { + files: Vec, + }, + SchemaInspection { + fingerprint: String, + objects: Vec, + starter_file: Option, + }, + Check { + contract_revision: Option, + production: bool, + configuration_key_paths: Option, + }, + Generate { + contract_revision: Option, + artifacts: Vec, + }, + Test { + contract_revision: Option, + report: Option, + }, + Diff { + report: Option, + }, + Package { + manifest: Option, + }, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ConfigurationKeyPaths { + pub registry: Vec, + pub runtime: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct InspectedObject { + pub kind: InspectedObjectKind, + pub name: String, + pub table_name: String, + pub columns: Vec, +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum InspectedObjectKind { + Table, + Index, + View, + Trigger, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct InspectedColumn { + pub name: String, + pub declared_type: String, + pub nullable: bool, + pub primary_key: bool, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct GeneratedFile { + pub id: String, + pub path: String, + pub sha256: String, +} + +#[derive(Debug, Error)] +pub enum ToolingError { + #[error("the requested authoring input could not be read")] + Read, + #[error("the requested authoring output could not be written")] + Write, + #[error("the requested path is unsafe")] + UnsafePath, + #[error("the SQLite schema could not be inspected")] + Inspect, + #[error("the generated artifacts could not be constructed")] + Generate, + #[error("the deployment package could not be constructed")] + Package, +} + +impl ToolingError { + /// A deliberately categorical message containing no source value or + /// absolute filesystem path. + pub fn safe_message(&self) -> &'static str { + match self { + Self::Read => "the requested authoring input could not be read", + Self::Write => "the requested authoring output could not be written", + Self::UnsafePath => "the requested path is unsafe", + Self::Inspect => "the SQLite schema could not be inspected", + Self::Generate => "the generated artifacts could not be constructed", + Self::Package => "the deployment package could not be constructed", + } + } +} + +pub fn init_project(options: &InitOptions) -> Result { + if options.project_root.exists() { + if fs::symlink_metadata(&options.project_root) + .map_err(|_| ToolingError::Read)? + .file_type() + .is_symlink() + { + return Err(ToolingError::UnsafePath); + } + let mut entries = fs::read_dir(&options.project_root).map_err(|_| ToolingError::Read)?; + if entries.next().is_some() { + return Ok(ToolingReport::refused( + vec![diagnostic( + "project.destination_not_empty", + ".", + "initialization requires a new or empty project directory", + )], + ToolingDetails::Initialized { files: Vec::new() }, + )); + } + } else { + fs::create_dir(&options.project_root).map_err(|_| ToolingError::Write)?; + } + let files = [ + ("registry.yaml", STARTER_REGISTRY), + ("runtime.yaml", STARTER_RUNTIME), + ("governance/identifier-lifecycle.yaml", STARTER_LIFECYCLE), + ( + "governance/classification-review.yaml", + STARTER_CLASSIFICATION, + ), + ("governance/legal-basis.yaml", STARTER_LEGAL_BASIS), + ("governance/processing.dpv.yaml", STARTER_PROCESSING), + ("codelists/record-lifecycle.yaml", STARTER_CODELIST), + ]; + for (relative, content) in &files { + write_relative_new(&options.project_root, relative, content.as_bytes())?; + } + Ok(ToolingReport::success(ToolingDetails::Initialized { + files: files.iter().map(|(path, _)| (*path).to_owned()).collect(), + })) +} + +pub fn inspect_schema(options: &InspectOptions) -> Result { + let snapshot = + CapturedSnapshot::capture(&options.database_path).map_err(|_| ToolingError::Inspect)?; + let catalog = inspect_sqlite_schema(&DatabaseProfile::Snapshot(snapshot), &inspection_limits()) + .map_err(|_| ToolingError::Inspect)?; + let objects = catalog + .objects + .iter() + .map(|object| InspectedObject { + kind: inspected_kind(object.kind), + name: object.name.clone(), + table_name: object.table_name.clone(), + columns: object + .columns + .iter() + .map(|column| InspectedColumn { + name: column.name.clone(), + declared_type: column.declared_type.clone(), + nullable: column.nullable, + primary_key: column.primary_key, + }) + .collect(), + }) + .collect::>(); + let starter_file = if let Some(output) = &options.starter_output { + if output.exists() + && fs::symlink_metadata(output) + .map_err(|_| ToolingError::Read)? + .file_type() + .is_symlink() + { + return Err(ToolingError::UnsafePath); + } + fs::create_dir_all(output).map_err(|_| ToolingError::Write)?; + let starter = serde_norway::to_string(&SchemaStarter { + schema_fingerprint: &catalog.fingerprint, + review_status: "suggested", + objects: &objects, + }) + .map_err(|_| ToolingError::Write)?; + let path = output.join("schema-starter.yaml"); + fs::write(&path, starter).map_err(|_| ToolingError::Write)?; + Some("schema-starter.yaml".into()) + } else { + None + }; + Ok(ToolingReport::success(ToolingDetails::SchemaInspection { + fingerprint: catalog.fingerprint, + objects, + starter_file, + })) +} + +pub fn check_project(options: &CheckOptions) -> Result { + match compile_project( + &options.project_root, + if options.production { + CompileProfile::Production + } else { + CompileProfile::Authoring + }, + )? { + ProjectCompilation::Compiled(project) => { + let configuration_key_paths = ConfigurationKeyPaths { + registry: collect_configuration_key_paths( + &serde_json::to_value(&project.contract).map_err(|_| ToolingError::Inspect)?, + ), + runtime: project + .runtime + .as_ref() + .map(|runtime| serde_json::to_value(runtime).map_err(|_| ToolingError::Inspect)) + .transpose()? + .as_ref() + .map(collect_configuration_key_paths) + .unwrap_or_default(), + }; + Ok(ToolingReport::success(ToolingDetails::Check { + contract_revision: Some(project.registry.contract_revision), + production: options.production, + configuration_key_paths: Some(configuration_key_paths), + })) + } + ProjectCompilation::Refused(report) => Ok(ToolingReport::refused( + report.diagnostics, + ToolingDetails::Check { + contract_revision: None, + production: options.production, + configuration_key_paths: None, + }, + )), + } +} + +fn collect_configuration_key_paths(document: &serde_json::Value) -> Vec { + fn walk(value: &serde_json::Value, prefix: &str, paths: &mut BTreeSet) { + match value { + serde_json::Value::Object(values) => { + let dynamic_map = matches!( + prefix, + "sources" + | "resources[].sourceColumnClassifications" + | "resources[].properties" + | "resources[].disclosureProfiles" + | "resources[].operations.lookups[].requestBody.selectors" + ); + if dynamic_map { + let wildcard = format!("{prefix}.*"); + paths.insert(wildcard.clone()); + for child in values.values() { + walk(child, &wildcard, paths); + } + } else { + for (name, child) in values { + let path = if prefix.is_empty() { + name.clone() + } else { + format!("{prefix}.{name}") + }; + paths.insert(path.clone()); + walk(child, &path, paths); + } + } + } + serde_json::Value::Array(values) => { + let path = format!("{prefix}[]"); + paths.insert(path.clone()); + for child in values { + walk(child, &path, paths); + } + } + _ => {} + } + } + + let mut paths = BTreeSet::new(); + walk(document, "", &mut paths); + paths.into_iter().collect() +} + +pub fn generate_project(options: &GenerateOptions) -> Result { + let compilation = compile_project(&options.project_root, CompileProfile::Authoring)?; + let project = match compilation { + ProjectCompilation::Compiled(project) => project, + ProjectCompilation::Refused(report) => { + return Ok(ToolingReport::refused( + report.diagnostics, + ToolingDetails::Generate { + contract_revision: None, + artifacts: Vec::new(), + }, + )); + } + }; + let registry = project.registry; + let artifacts = generate_artifacts(®istry).map_err(|_| ToolingError::Generate)?; + let output = options + .output_dir + .clone() + .unwrap_or_else(|| options.project_root.join("generated")); + write_artifacts(&output, &artifacts)?; + let generated = artifacts + .artifacts + .iter() + .map(|artifact| GeneratedFile { + id: artifact.id.clone(), + path: artifact.path.clone(), + sha256: artifact.sha256.clone(), + }) + .collect(); + Ok(ToolingReport::success(ToolingDetails::Generate { + contract_revision: Some(registry.contract_revision), + artifacts: generated, + })) +} + +pub fn test_project(options: &TestOptions) -> Result { + let compilation = compile_project(&options.project_root, CompileProfile::Authoring)?; + let project = match compilation { + ProjectCompilation::Compiled(project) => project, + ProjectCompilation::Refused(report) => { + return Ok(ToolingReport::refused( + report.diagnostics, + ToolingDetails::Test { + contract_revision: None, + report: None, + }, + )); + } + }; + let CompiledProject { + contract, + registry, + runtime, + } = *project; + let fixture_yaml = read_utf8(&options.project_root.join("expected-http.yaml"))?; + let journey = match parse_journey(&fixture_yaml) { + Ok(journey) => journey, + Err(_) => { + return Ok(ToolingReport::refused( + vec![diagnostic( + "fixture.yaml_invalid", + "expected-http.yaml", + "the fixture journey is not valid strict YAML", + )], + ToolingDetails::Test { + contract_revision: Some(registry.contract_revision), + report: None, + }, + )); + } + }; + let Some(runtime) = runtime else { + return Ok(ToolingReport::refused( + vec![diagnostic( + "fixture.runtime_missing", + "runtime.yaml", + "offline fixture execution requires a deployment binding", + )], + ToolingDetails::Test { + contract_revision: Some(registry.contract_revision), + report: None, + }, + )); + }; + let fixture_sql = read_utf8(&options.project_root.join("fixture.sql"))?; + let temporary = tempfile::tempdir().map_err(|_| ToolingError::Write)?; + let database = temporary.path().join("fixture.sqlite"); + if materialize_fixture(&database, &fixture_sql).is_err() { + return Ok(ToolingReport::refused( + vec![diagnostic( + "fixture.database_invalid", + "fixture.sql", + "the synthetic SQLite fixture could not be materialized", + )], + ToolingDetails::Test { + contract_revision: Some(registry.contract_revision), + report: None, + }, + )); + } + let bindings = registry + .sources + .iter() + .map(|source| { + ( + source.id.clone(), + RuntimeSourceBinding { + path: database.clone(), + }, + ) + }) + .collect(); + let sqlite = match SqliteRuntime::open( + ®istry, + &bindings, + SqliteRuntimeLimits { + request_timeout: Duration::from_millis(runtime.limits.request_timeout_milliseconds), + concurrent_queries: usize::try_from(runtime.limits.concurrent_queries) + .unwrap_or(usize::MAX), + }, + ) { + Ok(sqlite) => sqlite, + Err(_) => { + return Ok(ToolingReport::refused( + vec![diagnostic( + "fixture.source_unavailable", + "fixture.sql", + "the synthetic SQLite fixture does not satisfy the compiled source contract", + )], + ToolingDetails::Test { + contract_revision: Some(registry.contract_revision), + report: None, + }, + )); + } + }; + let executor = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|_| ToolingError::Inspect)?; + let artifacts = generate_artifacts(®istry).map_err(|_| ToolingError::Generate)?; + let cursor_key = if runtime.cursor.is_some() { + Some(Arc::new( + CursorKey::new(vec![0x5a; 32]).map_err(|_| ToolingError::Generate)?, + )) + } else { + None + }; + let cursor_maximum_age = runtime.cursor.as_ref().map_or(Duration::ZERO, |cursor| { + Duration::from_secs(cursor.maximum_age_seconds) + }); + let metadata = ServiceMetadata { + authority: InstitutionMetadata { + identifier: contract.registry.authority.identifier.clone(), + name: contract.registry.authority.name.clone(), + }, + operator: contract + .registry + .operator + .as_ref() + .map(|operator| InstitutionMetadata { + identifier: operator.identifier.clone(), + name: operator.name.clone(), + }), + authoritative_scope: contract.registry.authoritative_scope.clone(), + alignment_targets: contract + .registry + .alignment_targets + .iter() + .map(|target| AlignmentMetadata { + name: target.name.clone(), + version: target.version.clone(), + status: target.status.clone(), + cfr_target: target.cfr_target.clone(), + }) + .collect(), + }; + let registry = Arc::new(registry); + let quota = runtime.quotas.as_ref().map(|quota| QuotaConfig { + requests_per_minute: quota.requests_per_minute, + burst: quota.burst, + }); + let sink = Arc::new(JsonlFileSink::new(temporary.path().join("audit.jsonl"))); + let fixture_report = executor + .block_on(async { + let chain = Arc::new( + ChainState::bootstrap_unkeyed_dev_only(sink.as_ref()) + .await + .map_err(|_| ())?, + ); + let audit = RelayAudit::new(chain, sink); + let service = Arc::new(RelayService::new( + Arc::clone(®istry), + Arc::new(artifacts), + Arc::new(sqlite), + fixture_authenticator(&journey), + audit, + cursor_key, + cursor_maximum_age, + Duration::from_millis(runtime.limits.request_timeout_milliseconds), + quota, + metadata, + )); + Ok::<_, ()>( + execute_fixture_journey( + registry.as_ref(), + router(service), + &journey, + options.fixture_id.as_deref(), + ) + .await, + ) + }) + .map_err(|_| ToolingError::Inspect)?; + let details = ToolingDetails::Test { + contract_revision: Some(registry.contract_revision.clone()), + report: Some(fixture_report.clone()), + }; + if fixture_report.is_success() { + Ok(ToolingReport::success(details)) + } else { + Ok(ToolingReport::refused( + fixture_report + .diagnostics + .iter() + .map(|item| diagnostic(&item.code, &item.location, &item.message)) + .collect(), + details, + )) + } +} + +pub fn diff_projects(options: &DiffOptions) -> Result { + let previous = compile_project(&options.previous_root, CompileProfile::Authoring)?; + let current = compile_project(&options.current_root, CompileProfile::Authoring)?; + let (previous, current) = match (previous, current) { + (ProjectCompilation::Compiled(previous), ProjectCompilation::Compiled(current)) => { + (previous.registry, current.registry) + } + (previous, current) => { + let mut diagnostics = Vec::new(); + if let ProjectCompilation::Refused(report) = previous { + diagnostics.extend(report.diagnostics); + } + if let ProjectCompilation::Refused(report) = current { + diagnostics.extend(report.diagnostics); + } + diagnostics.sort_by(|left, right| { + left.location + .cmp(&right.location) + .then(left.code.cmp(&right.code)) + }); + return Ok(ToolingReport::refused( + diagnostics, + ToolingDetails::Diff { report: None }, + )); + } + }; + Ok(ToolingReport::success(ToolingDetails::Diff { + report: Some(diff_registries(&previous, ¤t)), + })) +} + +pub fn package_project(options: &PackageOptions) -> Result { + let compilation = compile_project(&options.project_root, CompileProfile::Production)?; + let project = match compilation { + ProjectCompilation::Compiled(project) => project, + ProjectCompilation::Refused(report) => { + return Ok(ToolingReport::refused( + report.diagnostics, + ToolingDetails::Package { manifest: None }, + )); + } + }; + let CompiledProject { + contract, registry, .. + } = *project; + let artifacts = generate_artifacts(®istry).map_err(|_| ToolingError::Generate)?; + let manifest = build_package( + &options.project_root, + &options.output_dir, + &contract, + ®istry, + &artifacts, + ) + .map_err(|_| ToolingError::Package)?; + Ok(ToolingReport::success(ToolingDetails::Package { + manifest: Some(manifest), + })) +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct SchemaStarter<'a> { + schema_fingerprint: &'a str, + review_status: &'static str, + objects: &'a [InspectedObject], +} + +enum ProjectCompilation { + Compiled(Box), + Refused(CompileReport), +} + +struct CompiledProject { + contract: RegistryContract, + #[allow(dead_code)] + runtime: Option, + registry: CompiledRegistry, +} + +fn compile_project( + root: &Path, + profile: CompileProfile, +) -> Result { + let contract_yaml = read_utf8(&root.join("registry.yaml"))?; + let contract = match RegistryContract::parse_yaml(&contract_yaml) { + Ok(contract) => contract, + Err(_) => { + return Ok(ProjectCompilation::Refused(CompileReport { + diagnostics: vec![diagnostic( + "contract.yaml_invalid", + "registry.yaml", + "the governed contract is not valid strict YAML", + )], + })); + } + }; + let runtime_path = root.join("runtime.yaml"); + let runtime = if runtime_path.is_file() { + let yaml = read_utf8(&runtime_path)?; + match RelayRuntime::parse_yaml(&yaml) { + Ok(runtime) => Some(runtime), + Err(_) => { + return Ok(ProjectCompilation::Refused(CompileReport { + diagnostics: vec![diagnostic( + "runtime.yaml_invalid", + "runtime.yaml", + "the deployment binding is not valid strict YAML", + )], + })); + } + } + } else { + None + }; + let mut diagnostics = validate_runtime(&contract, runtime.as_ref()); + let observed = match runtime.as_ref() { + Some(runtime) => { + observe_sources(root, &contract, runtime).map_err(|_| ToolingError::Inspect)? + } + None => Vec::new(), + }; + if profile == CompileProfile::Production && observed.len() != contract.sources.len() { + diagnostics.push(diagnostic( + "runtime.source_unavailable", + "runtime.yaml.sources", + "one or more source bindings could not be observed", + )); + } + let governed_files = capture_governed_files(root, &contract)?; + match compile_contract_with_governed_files(&contract, &observed, profile, &governed_files) { + Ok(registry) if diagnostics.is_empty() => { + Ok(ProjectCompilation::Compiled(Box::new(CompiledProject { + contract, + runtime, + registry, + }))) + } + Ok(_) => Ok(ProjectCompilation::Refused(CompileReport { diagnostics })), + Err(mut report) => { + diagnostics.append(&mut report.diagnostics); + diagnostics.sort_by(|left, right| { + left.location + .cmp(&right.location) + .then(left.code.cmp(&right.code)) + }); + Ok(ProjectCompilation::Refused(CompileReport { diagnostics })) + } + } +} + +fn capture_governed_files( + root: &Path, + contract: &RegistryContract, +) -> Result { + let mut references = BTreeSet::new(); + references.insert(contract.registry.identifier_lifecycle_policy_ref.as_str()); + references.insert(contract.classifications.provenance_ref.as_str()); + for alignment in &contract.semantics.alignments { + references.insert(alignment.profile_ref.as_str()); + } + for resource in &contract.resources { + references.insert(resource.record_context.lifecycle_state.codelist.as_str()); + for (_, property) in resource.properties.iter() { + if let Some(codelist) = property.codelist.as_deref() { + references.insert(codelist); + } + } + for processing in &resource.processing_descriptions { + references.insert(processing.legal_basis_ref.as_str()); + references.insert(processing.dpv_profile_ref.as_str()); + } + } + let canonical_root = root.canonicalize().map_err(|_| ToolingError::Read)?; + let mut files = GovernedFileSet::new(); + for reference in references { + validate_relative(reference)?; + let candidate = canonical_root.join(reference); + let Ok(metadata) = fs::symlink_metadata(&candidate) else { + continue; + }; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(ToolingError::UnsafePath); + } + let canonical = candidate.canonicalize().map_err(|_| ToolingError::Read)?; + if !canonical.starts_with(&canonical_root) { + return Err(ToolingError::UnsafePath); + } + files.insert( + reference.into(), + fs::read(canonical).map_err(|_| ToolingError::Read)?, + ); + } + Ok(files) +} + +fn validate_runtime( + contract: &RegistryContract, + runtime: Option<&RelayRuntime>, +) -> Vec { + let mut diagnostics = Vec::new(); + let Some(runtime) = runtime else { + return diagnostics; + }; + if runtime.api_version != "relay.registrystack.org/v2alpha1" || runtime.kind != "RelayRuntime" { + diagnostics.push(diagnostic( + "runtime.identity_invalid", + "runtime.yaml", + "the deployment document identity is unsupported", + )); + } + let governed = contract.sources.keys().collect::>(); + let bound = runtime.sources.keys().collect::>(); + if governed != bound { + diagnostics.push(diagnostic( + "runtime.source_binding_mismatch", + "runtime.yaml.sources", + "runtime sources must bind exactly the governed source identifiers", + )); + } + if contract + .resources + .iter() + .flat_map(|resource| resource.operations.list.iter()) + .next() + .is_some() + && runtime.cursor.is_none() + { + diagnostics.push(diagnostic( + "runtime.cursor_missing", + "runtime.yaml.cursor", + "a Registry with a list operation requires an opaque-cursor key and age bound", + )); + } + let protected = contract.resources.iter().any(|resource| { + resource + .operations + .list + .iter() + .map(|operation| &operation.access) + .chain( + resource + .operations + .read + .iter() + .map(|operation| &operation.access), + ) + .chain( + resource + .operations + .lookups + .iter() + .map(|operation| &operation.access), + ) + .any(|access| matches!(access, crate::contract::AccessRule::Protected(_))) + }); + if protected && runtime.authentication.issuer.is_none() { + diagnostics.push(diagnostic( + "runtime.issuer_missing", + "runtime.yaml.authentication.issuer", + "a Registry with protected operations requires one configured issuer", + )); + } + diagnostics +} + +fn inspected_kind(kind: SchemaObjectKind) -> InspectedObjectKind { + match kind { + SchemaObjectKind::Table => InspectedObjectKind::Table, + SchemaObjectKind::Index => InspectedObjectKind::Index, + SchemaObjectKind::View => InspectedObjectKind::View, + SchemaObjectKind::Trigger => InspectedObjectKind::Trigger, + } +} + +fn write_artifacts(output: &Path, artifacts: &ArtifactSet) -> Result<(), ToolingError> { + if output.exists() + && fs::symlink_metadata(output) + .map_err(|_| ToolingError::Read)? + .file_type() + .is_symlink() + { + return Err(ToolingError::UnsafePath); + } + fs::create_dir_all(output).map_err(|_| ToolingError::Write)?; + for artifact in &artifacts.artifacts { + validate_relative(&artifact.path)?; + let path = output.join(&artifact.path); + reject_existing_symlink_components(output, Path::new(&artifact.path))?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|_| ToolingError::Write)?; + } + fs::write(path, &artifact.content).map_err(|_| ToolingError::Write)?; + } + Ok(()) +} + +fn reject_existing_symlink_components(root: &Path, relative: &Path) -> Result<(), ToolingError> { + let mut current = root.to_path_buf(); + for component in relative.components() { + let Component::Normal(component) = component else { + return Err(ToolingError::UnsafePath); + }; + current.push(component); + match fs::symlink_metadata(¤t) { + Ok(metadata) if metadata.file_type().is_symlink() => { + return Err(ToolingError::UnsafePath); + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => break, + Err(_) => return Err(ToolingError::Read), + } + } + Ok(()) +} + +fn write_relative_new(root: &Path, relative: &str, content: &[u8]) -> Result<(), ToolingError> { + validate_relative(relative)?; + let path = root.join(relative); + if path.exists() { + return Err(ToolingError::Write); + } + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|_| ToolingError::Write)?; + } + fs::write(path, content).map_err(|_| ToolingError::Write) +} + +fn validate_relative(value: &str) -> Result<(), ToolingError> { + let path = Path::new(value); + if path.is_absolute() + || path.components().any(|component| { + matches!( + component, + Component::ParentDir | Component::RootDir | Component::Prefix(_) + ) + }) + { + return Err(ToolingError::UnsafePath); + } + Ok(()) +} + +fn read_utf8(path: &Path) -> Result { + fs::read_to_string(path).map_err(|_| ToolingError::Read) +} + +fn diagnostic(code: &str, location: &str, message: &str) -> Diagnostic { + Diagnostic { + severity: DiagnosticSeverity::Error, + code: code.into(), + location: location.into(), + message: message.into(), + } +} + +const STARTER_REGISTRY: &str = r#"apiVersion: relay.registrystack.org/v2alpha1 +kind: RegistryContract +metadata: {id: registry, version: draft-1, title: Registry authoring workspace} +registry: + registryIdentifier: urn:example:registry:registry + name: Registry authoring workspace + authority: {identifier: urn:example:authority, name: Registry Authority} + authoritativeScope: Reviewed authoritative records in the declared jurisdiction + baseUri: https://registry.example.invalid/ + identifierLifecyclePolicyRef: governance/identifier-lifecycle.yaml + alignmentTargets: + - {name: govstack-digital-registries, version: 3.0.0-alpha.2, status: directional} + - {name: govstack-api-design-guide, version: 0.1.0-draft, status: directional} +governance: {controller: urn:example:authority, publisher: urn:example:authority, auditOwner: urn:example:audit-owner} +semantics: {localVocabulary: https://registry.example.invalid/vocabulary/} +classifications: + privacy: {scheme: https://w3id.org/dpv, version: "2.3"} + institutional: {scheme: urn:example:classification, version: draft-1} + handling: {scheme: https://id.registrystack.org/vocab/handling, version: "1"} + provenanceRef: governance/classification-review.yaml +sources: + registry: {kind: sqlite, profile: snapshot, expectedSchemaFingerprint: "sha256:0000000000000000000000000000000000000000000000000000000000000000"} +resources: + - id: record + title: Record + description: Unreviewed starter Record resource + semanticClass: local:Record + source: {source: registry, view: registry_records} + classificationDefaults: {privacy: non-personal, institutional: internal, handling: internal, status: suggested} + recordContext: + recordIdentifier: {sourceColumn: record_identifier} + revisionIdentifier: {sourceColumn: revision_identifier} + lifecycleState: {sourceColumn: lifecycle_state, codelist: codelists/record-lifecycle.yaml} + recordedAt: {sourceColumn: recorded_at} + sourceColumnClassifications: {} + properties: + recordValue: {label: Record value, description: Unreviewed starter property, sourceColumn: record_value, type: string, sourceRequired: true, semanticTerm: "local:recordValue"} + disclosureProfiles: {default: {properties: [recordValue]}} + operations: + read: {access: {scope: "registry:record:read"}, disclosureProfile: default} + processingDescriptions: + - {id: consultation, operationRefs: [read], purpose: reviewed-consultation, recipientClass: authorized-client, legalBasisRef: governance/legal-basis.yaml, dpvProfileRef: governance/processing.dpv.yaml, safeguards: [property-minimization]} +metadataVisibility: {service: public, resources: operation-bound, semantics: operation-bound, classifications: operator-only, processing: operation-bound} +"#; + +const STARTER_RUNTIME: &str = r#"apiVersion: relay.registrystack.org/v2alpha1 +kind: RelayRuntime +server: {bind: "127.0.0.1:8080"} +packagePath: package +sources: {registry: {path: registry.sqlite}} +authentication: {issuer: null} +audit: {sink: var/audit.jsonl, integrityKeyRef: secret:env/RELAY_AUDIT_KEY} +limits: {requestTimeoutMilliseconds: 1500, concurrentQueries: 8} +"#; + +const STARTER_LIFECYCLE: &str = + "status: suggested\npolicy: Identifiers are stable and are not reassigned after retirement.\n"; +const STARTER_CLASSIFICATION: &str = + "status: suggested\nreview: Institutional review is required before production packaging.\n"; +const STARTER_LEGAL_BASIS: &str = "status: suggested\nlegalBasis: Institutional review is required before production packaging.\n"; +const STARTER_PROCESSING: &str = "status: suggested\nprofile: https://w3id.org/dpv/2.3\n"; +const STARTER_CODELIST: &str = + "id: record-lifecycle\nversion: draft-1\nvalues: [ACTIVE, RETIRED]\nstatus: suggested\n"; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn errors_never_render_paths() { + for error in [ + ToolingError::Read, + ToolingError::Write, + ToolingError::UnsafePath, + ToolingError::Inspect, + ToolingError::Generate, + ToolingError::Package, + ] { + assert!(!error.safe_message().contains('/')); + } + } + + #[test] + fn initialized_contract_is_strictly_parseable() { + assert!(RegistryContract::parse_yaml(STARTER_REGISTRY).is_ok()); + assert!(RelayRuntime::parse_yaml(STARTER_RUNTIME).is_ok()); + } + + #[test] + fn fixture_execution_is_isolated_and_reports_no_row_values() { + let project_name = ["business", "registry"].join("-"); + let project = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../products/relay-v2/acceptance") + .join(project_name); + let runtime_path = project.join("runtime.yaml"); + let source_path = project.join("fixture.sql"); + let runtime_before = fs::read(&runtime_path).expect("runtime reads"); + let source_before = fs::read(&source_path).expect("fixture source reads"); + assert!(!project.join("fixture.sqlite").exists()); + + let report = test_project(&TestOptions { + project_root: project.clone(), + fixture_id: Some("identifier-read".into()), + }) + .expect("fixture operation completes"); + assert!(report.is_success(), "{report:?}"); + + assert_eq!( + fs::read(runtime_path).expect("runtime rereads"), + runtime_before + ); + assert_eq!( + fs::read(source_path).expect("fixture source rereads"), + source_before + ); + assert!(!project.join("fixture.sqlite").exists()); + let rendered = serde_json::to_string(&report).expect("report serializes"); + for protected in [ + "Example Orchard Cooperative", + "BIZ-SYNTH-0001", + "registration_number", + ] { + assert!(!rendered.contains(protected), "report leaked fixture data"); + } + } +} diff --git a/crates/registry-relay-v2/tests/acceptance_http.rs b/crates/registry-relay-v2/tests/acceptance_http.rs new file mode 100644 index 000000000..a6d860207 --- /dev/null +++ b/crates/registry-relay-v2/tests/acceptance_http.rs @@ -0,0 +1,1572 @@ +// SPDX-License-Identifier: Apache-2.0 + +use std::collections::{BTreeMap, BTreeSet}; +use std::convert::Infallible; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use axum::body::{to_bytes, Body}; +use bytes::Bytes; +use futures::stream; +use http::header::{AUTHORIZATION, CACHE_CONTROL, CONTENT_TYPE, ETAG, VARY}; +use http::{HeaderMap, HeaderName, HeaderValue, Method, Request, StatusCode}; +use registry_platform_audit::{ + AuditChainHasher, AuditEnvelope, AuditError, AuditSink, ChainState, JsonlFileSink, +}; +use registry_platform_httputil::FetchUrlPolicy; +use registry_platform_oidc::{JwksFetcher, JwksFetcherConfig, TokenVerifier}; +use registry_platform_sqlite::{ + inspect_schema, materialize_fixture, CapturedSnapshot, DatabaseProfile, InspectionLimits, + SchemaObjectKind, +}; +use registry_platform_testing::{ + fixtures, oidc_verifier_config, sign_ed25519_compact_jwt, MockIdp, +}; +use registry_relay_v2::artifacts::generate_artifacts; +use registry_relay_v2::audit::RelayAudit; +use registry_relay_v2::auth::RelayAuthenticator; +use registry_relay_v2::compiler::{compile_contract_with_governed_files, GovernedFileSet}; +use registry_relay_v2::contract::{RegistryContract, RelayRuntime}; +use registry_relay_v2::model::{ + CompileProfile, ObservedColumn, ObservedSourceSchema, ObservedView, +}; +use registry_relay_v2::server::{ + router, AlignmentMetadata, InstitutionMetadata, QuotaConfig, RelayService, ServiceMetadata, +}; +use registry_relay_v2::sqlite_runtime::{RuntimeSourceBinding, SqliteRuntime, SqliteRuntimeLimits}; +use serde::Deserialize; +use serde_json::{json, Value}; +use tempfile::TempDir; +use tower::ServiceExt as _; + +const ACCEPTANCE_ROOT: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../products/relay-v2/acceptance" +); +const PROJECTS: [&str; 3] = ["social-assistance", "business-registry", "civil-event"]; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct Journey { + schema_version: String, + registry: String, + #[serde(default)] + authorizations: BTreeMap, + steps: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct AuthorizationFixture { + principal: String, + scopes: BTreeSet, + #[serde(default)] + claims: BTreeMap, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct JourneyStep { + id: String, + #[serde(default)] + authorization_fixture: Option, + request: JourneyRequest, + expect: JourneyExpectation, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct JourneyRequest { + method: String, + path: String, + #[serde(default)] + headers: BTreeMap, + #[serde(default)] + query: BTreeMap, + #[serde(default)] + body: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +#[serde(rename_all = "camelCase")] +struct JourneyExpectation { + status: u16, + #[serde(default)] + capability_patterns: Vec, + #[serde(default)] + absent_capability_patterns: Vec, + #[serde(default)] + item_count: Option, + #[serde(default)] + next_cursor: Option, + #[serde(default)] + registry_core_required: bool, + #[serde(default)] + domain_data_keys: Vec, + #[serde(default)] + record_identifier: Option, + #[serde(default)] + cache: Option, + #[serde(default)] + code: Option, + #[serde(default)] + route_absent: bool, + #[serde(default)] + equivalence_class: Option, + #[serde(default)] + absent_everywhere: Vec, + #[serde(default)] + records_equivalent_to: Option, + #[serde(default)] + body_empty: bool, + #[serde(default)] + etag_same_as: Option, +} + +struct ProjectHarness { + app: axum::Router, + service: Arc, + contract: RegistryContract, + runtime: RelayRuntime, + database: PathBuf, + idp: Option, + _temp: TempDir, +} + +struct ControlledAuditSink { + fail_on_write: usize, + writes: AtomicUsize, +} + +impl ControlledAuditSink { + fn new(fail_on_write: usize) -> Self { + Self { + fail_on_write, + writes: AtomicUsize::new(0), + } + } + + fn writes(&self) -> usize { + self.writes.load(Ordering::SeqCst) + } +} + +#[async_trait::async_trait] +impl AuditSink for ControlledAuditSink { + async fn write(&self, _envelope: &AuditEnvelope) -> Result<(), AuditError> { + let write = self.writes.fetch_add(1, Ordering::SeqCst) + 1; + if write == self.fail_on_write { + return Err(AuditError::Io(std::io::Error::other( + "controlled audit failure", + ))); + } + Ok(()) + } + + #[allow(deprecated)] + async fn tail_hash(&self) -> Result, AuditError> { + Ok(None) + } + + async fn tail_hash_with_hasher( + &self, + _hasher: &AuditChainHasher, + ) -> Result, AuditError> { + Ok(None) + } +} + +#[tokio::test] +async fn all_three_registry_http_journeys_use_the_real_router() { + let selected = std::env::var("RELAY_V2_ACCEPTANCE_PROJECT").ok(); + if let Some(selected) = &selected { + assert!( + PROJECTS.contains(&selected.as_str()), + "selected acceptance project is unknown" + ); + } + for project in PROJECTS.into_iter().filter(|project| { + selected + .as_deref() + .is_none_or(|selected| selected == *project) + }) { + let mut harness = ProjectHarness::open(project).await; + let journey: Journey = serde_norway::from_slice( + &fs::read(project_root(project).join("expected-http.yaml")).expect("journey reads"), + ) + .expect("journey parses"); + assert_eq!( + journey.schema_version, + "relay.registrystack.org/http-journey/v1alpha1" + ); + assert_eq!( + journey.registry, + harness.contract.registry.registry_identifier + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("loopback listener binds"); + let address = listener.local_addr().expect("loopback address resolves"); + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); + let app = harness.app.clone(); + let server = tokio::spawn(async move { + axum::serve(listener, app) + .with_graceful_shutdown(async move { + let _ = shutdown_rx.await; + }) + .await + }); + let client = reqwest::Client::builder() + .no_proxy() + .timeout(Duration::from_secs(10)) + .build() + .expect("loopback client builds"); + let mut equivalence_classes = BTreeMap::new(); + let mut response_documents = BTreeMap::new(); + let mut etags = BTreeMap::new(); + for step in journey.steps { + let request = harness.request_with_observations( + &step, + &journey.authorizations, + &response_documents, + &etags, + ); + let response = send_loopback_request(&client, address, request) + .await + .unwrap_or_else(|error| panic!("{project}/{} request failed: {error}", step.id)); + let status = response.status(); + let headers = response.headers().clone(); + let body = response.bytes().await.expect("response body reads"); + assert_eq!( + status, + StatusCode::from_u16(step.expect.status).expect("expected status is valid"), + "{project}/{} returned the wrong status; body={}", + step.id, + String::from_utf8_lossy(&body) + ); + if let Some(reference) = &step.expect.etag_same_as { + assert_eq!( + headers.get(ETAG).and_then(|value| value.to_str().ok()), + etags.get(reference).map(String::as_str), + "{project}/{} ETag must match {reference}", + step.id + ); + } + if let Some(etag) = headers.get(ETAG).and_then(|value| value.to_str().ok()) { + etags.insert(step.id.clone(), etag.to_owned()); + } + assert_expectations(project, &step, &headers, &body, &mut equivalence_classes); + if !body.is_empty() { + let document: Value = serde_json::from_slice(&body).expect("response is JSON"); + if let Some(reference) = &step.expect.records_equivalent_to { + let expected = response_documents + .get(reference) + .unwrap_or_else(|| panic!("referenced response {reference} exists")); + assert_eq!( + normalized_records(&document), + normalized_records(expected), + "{project}/{} Record values must match {reference}", + step.id + ); + } + response_documents.insert(step.id.clone(), document); + } + } + shutdown_tx.send(()).expect("loopback server is running"); + tokio::time::timeout(Duration::from_secs(5), server) + .await + .expect("loopback server shuts down before timeout") + .expect("loopback server task completes") + .expect("loopback server shuts down cleanly"); + if let Some(idp) = harness.idp.take() { + idp.stop().await; + } + } +} + +async fn send_loopback_request( + client: &reqwest::Client, + address: std::net::SocketAddr, + request: Request, +) -> reqwest::Result { + let (parts, body) = request.into_parts(); + let body = to_bytes(body, 1024 * 1024) + .await + .expect("journey request body reads"); + client + .request( + reqwest::Method::from_bytes(parts.method.as_str().as_bytes()) + .expect("journey method converts"), + format!("http://{address}{}", parts.uri), + ) + .headers(parts.headers) + .body(body) + .send() + .await +} + +#[tokio::test] +async fn readiness_fails_value_free_for_missing_replaced_and_drifted_sources() { + for project in ["business-registry", "social-assistance"] { + let harness = ProjectHarness::open(project).await; + assert!(harness.service.is_ready().await, "{project} starts ready"); + fs::remove_file(&harness.database).expect("source removes"); + assert_unready(&harness, project, "missing").await; + + let harness = ProjectHarness::open(project).await; + assert!(harness.service.is_ready().await, "{project} starts ready"); + let old = harness.database.with_extension("old.sqlite"); + fs::rename(&harness.database, &old).expect("bound source moves"); + materialize_fixture( + &harness.database, + &fs::read_to_string(project_root(project).join("fixture.sql")) + .expect("fixture SQL reads"), + ) + .expect("replacement materializes"); + assert_unready(&harness, project, "replaced").await; + + let harness = ProjectHarness::open(project).await; + assert!(harness.service.is_ready().await, "{project} starts ready"); + let drift = harness.database.with_extension("drift.sqlite"); + let mut changed_sql = fs::read_to_string(project_root(project).join("fixture.sql")) + .expect("fixture SQL reads"); + changed_sql.push_str("\nCREATE TABLE readiness_schema_drift (id TEXT);\n"); + materialize_fixture(&drift, &changed_sql).expect("drifted source materializes"); + make_writable(&harness.database); + fs::copy(&drift, &harness.database).expect("source drifts in place"); + make_read_only(&harness.database); + assert_unready(&harness, project, "drifted").await; + } + + let harness = ProjectHarness::open("business-registry").await; + fs::write( + format!("{}-wal", harness.database.display()), + b"synthetic uncheckpointed sidecar", + ) + .expect("snapshot sidecar writes"); + assert_unready(&harness, "business-registry", "sidecar").await; +} + +#[tokio::test] +async fn social_live_update_is_consistent_and_truthfully_unversioned() { + let harness = ProjectHarness::open("social-assistance").await; + let journey: Journey = serde_norway::from_slice( + &fs::read(project_root("social-assistance").join("expected-http.yaml")) + .expect("journey reads"), + ) + .expect("journey parses"); + let step = journey + .steps + .iter() + .find(|step| step.id == "lookup-default") + .expect("default lookup exists"); + + let before = harness + .app + .clone() + .oneshot(harness.request(step, &journey.authorizations)) + .await + .expect("router responds"); + assert_eq!(before.status(), StatusCode::OK); + assert_eq!( + before.headers().get(CACHE_CONTROL), + Some(&HeaderValue::from_static("no-store")) + ); + assert!(!before.headers().contains_key(ETAG)); + let before = to_bytes(before.into_body(), 1024 * 1024) + .await + .expect("response reads"); + let before: Value = serde_json::from_slice(&before).expect("response parses"); + assert_eq!( + before.pointer("/data/revisionIdentifier"), + Some(&json!("3")) + ); + assert_eq!( + before.pointer("/data/domainData/enrolmentStatus"), + Some(&json!("ELIGIBLE")) + ); + + make_writable(&harness.database); + materialize_fixture( + &harness.database, + "UPDATE source_assistance_enrolments \ + SET record_revision = '4', enrolment_status = 'SUSPENDED', valid_through = NULL \ + WHERE enrolment_reference = 'ENROL-SYNTH-0001';", + ) + .expect("trusted publisher update commits"); + + let after = harness + .app + .clone() + .oneshot(harness.request(step, &journey.authorizations)) + .await + .expect("router responds"); + assert_eq!(after.status(), StatusCode::OK); + assert_eq!( + after.headers().get(CACHE_CONTROL), + Some(&HeaderValue::from_static("no-store")) + ); + assert!(!after.headers().contains_key(ETAG)); + let after = to_bytes(after.into_body(), 1024 * 1024) + .await + .expect("response reads"); + let after: Value = serde_json::from_slice(&after).expect("response parses"); + assert_eq!(after.pointer("/data/revisionIdentifier"), Some(&json!("4"))); + assert_eq!( + after.pointer("/data/domainData/enrolmentStatus"), + Some(&json!("SUSPENDED")) + ); + assert!(after.pointer("/data/domainData/validThrough").is_none()); + assert_eq!( + after.pointer("/meta/sourceRevision"), + Some(&json!({"profile": "live", "status": "unversioned", "value": null})) + ); +} + +#[tokio::test] +async fn trusted_purpose_and_row_binding_refusals_use_only_verified_claims() { + let harness = ProjectHarness::open("social-assistance").await; + let journey: Journey = serde_norway::from_slice( + &fs::read(project_root("social-assistance").join("expected-http.yaml")) + .expect("journey reads"), + ) + .expect("journey parses"); + for (step_id, status) in [ + ("missing-purpose", StatusCode::FORBIDDEN), + ("wrong-purpose", StatusCode::FORBIDDEN), + ("missing-binding", StatusCode::FORBIDDEN), + ("wrong-binding", StatusCode::NOT_FOUND), + ] { + let step = journey + .steps + .iter() + .find(|step| step.id == step_id) + .unwrap_or_else(|| panic!("{step_id} journey exists")); + let response = harness + .app + .clone() + .oneshot(harness.request(step, &journey.authorizations)) + .await + .expect("router responds"); + let expected_code = if status == StatusCode::FORBIDDEN { + "consultation.denied" + } else { + "consultation.unresolved" + }; + assert_problem_code(response, status, expected_code).await; + } +} + +#[tokio::test] +async fn audit_attempt_failure_prevents_source_access() { + let sink = Arc::new(ControlledAuditSink::new(1)); + let harness = ProjectHarness::open_with_audit( + "business-registry", + Some(Arc::clone(&sink) as Arc), + ) + .await; + let response = harness + .app + .oneshot( + Request::builder() + .uri("/v2/resources/registered-business/records/BIZ-SYNTH-0001") + .body(Body::empty()) + .expect("request builds"), + ) + .await + .expect("router responds"); + let body = response_body(response, StatusCode::SERVICE_UNAVAILABLE).await; + assert_eq!( + body.get("code").and_then(Value::as_str), + Some("audit.unavailable") + ); + assert_eq!( + sink.writes(), + 1, + "source path must stop at failed attempt audit" + ); +} + +#[tokio::test] +async fn audit_terminal_failure_discards_held_record_bytes() { + let sink = Arc::new(ControlledAuditSink::new(2)); + let harness = ProjectHarness::open_with_audit( + "business-registry", + Some(Arc::clone(&sink) as Arc), + ) + .await; + let response = harness + .app + .oneshot( + Request::builder() + .uri("/v2/resources/registered-business/records/BIZ-SYNTH-0001") + .body(Body::empty()) + .expect("request builds"), + ) + .await + .expect("router responds"); + let body = response_body(response, StatusCode::SERVICE_UNAVAILABLE).await; + assert_eq!( + body.get("code").and_then(Value::as_str), + Some("audit.unavailable") + ); + let wire = serde_json::to_string(&body).expect("problem serializes"); + assert!(!wire.contains("BIZ-SYNTH-0001")); + assert!(!wire.contains("Example Orchard Cooperative")); + assert_eq!( + sink.writes(), + 2, + "release must stop at failed terminal audit" + ); +} + +#[tokio::test] +async fn real_jwt_path_rejects_malformed_audience_time_and_expired_tokens() { + let harness = ProjectHarness::open("social-assistance").await; + let journey: Journey = serde_norway::from_slice( + &fs::read(project_root("social-assistance").join("expected-http.yaml")) + .expect("journey reads"), + ) + .expect("journey parses"); + let step = journey + .steps + .iter() + .find(|step| step.id == "lookup-success") + .expect("protected lookup journey exists"); + let fixture_id = step + .authorization_fixture + .as_deref() + .expect("lookup has authorization fixture"); + let fixture = journey + .authorizations + .get(fixture_id) + .expect("authorization fixture resolves"); + + let malformed = request_with_bearer(&harness, step, &journey.authorizations, "malformed"); + assert_problem_code( + harness + .app + .clone() + .oneshot(malformed) + .await + .expect("router responds"), + StatusCode::UNAUTHORIZED, + "auth.invalid_credential", + ) + .await; + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock is valid") + .as_secs(); + let expected_audience = &harness + .runtime + .authentication + .issuer + .as_ref() + .expect("issuer exists") + .audience; + let multiple_audiences = harness.signed_token_with_audience( + fixture_id, + fixture, + json!([expected_audience, "urn:example:secondary-audience"]), + now, + now, + now.saturating_add(900), + ); + assert_problem_code( + harness + .app + .clone() + .oneshot(request_with_bearer( + &harness, + step, + &journey.authorizations, + &multiple_audiences, + )) + .await + .expect("router responds"), + StatusCode::UNAUTHORIZED, + "auth.invalid_credential", + ) + .await; + + let future_issued_at = now.saturating_add(300); + let future_issued = harness.signed_token( + fixture_id, + fixture, + expected_audience, + future_issued_at, + now, + future_issued_at.saturating_add(900), + ); + assert_problem_code( + harness + .app + .clone() + .oneshot(request_with_bearer( + &harness, + step, + &journey.authorizations, + &future_issued, + )) + .await + .expect("router responds"), + StatusCode::UNAUTHORIZED, + "auth.invalid_credential", + ) + .await; + + let wrong_audience = harness.signed_token( + fixture_id, + fixture, + "urn:example:wrong-audience", + now, + now, + now.saturating_add(900), + ); + assert_problem_code( + harness + .app + .clone() + .oneshot(request_with_bearer( + &harness, + step, + &journey.authorizations, + &wrong_audience, + )) + .await + .expect("router responds"), + StatusCode::UNAUTHORIZED, + "auth.invalid_credential", + ) + .await; + + let issued_at = now.saturating_sub(1_000); + let expired = harness.signed_token( + fixture_id, + fixture, + &harness + .runtime + .authentication + .issuer + .as_ref() + .expect("issuer exists") + .audience, + issued_at, + issued_at, + now.saturating_sub(120), + ); + assert_problem_code( + harness + .app + .clone() + .oneshot(request_with_bearer( + &harness, + step, + &journey.authorizations, + &expired, + )) + .await + .expect("router responds"), + StatusCode::UNAUTHORIZED, + "auth.invalid_credential", + ) + .await; +} + +#[tokio::test] +async fn operation_bound_metadata_is_no_store_and_links_only_visible_artifacts() { + let harness = ProjectHarness::open("social-assistance").await; + let journey: Journey = serde_norway::from_slice( + &fs::read(project_root("social-assistance").join("expected-http.yaml")) + .expect("journey reads"), + ) + .expect("journey parses"); + let step = journey + .steps + .iter() + .find(|step| step.id == "lookup-success") + .expect("protected lookup journey exists"); + let fixture_id = step + .authorization_fixture + .as_deref() + .expect("lookup has authorization fixture"); + let fixture = journey + .authorizations + .get(fixture_id) + .expect("authorization fixture resolves"); + let token = harness.token(fixture_id, fixture); + + for (uri, capability_pointer) in [ + ("/v2", "/capabilities/0"), + ("/v2/resources", "/items/0/capabilities/0"), + ("/v2/resources/assistance-enrolment", "/data/capabilities/0"), + ] { + let response = harness + .app + .clone() + .oneshot( + Request::builder() + .uri(uri) + .header(AUTHORIZATION, format!("Bearer {token}")) + .body(Body::empty()) + .expect("metadata request builds"), + ) + .await + .expect("router responds"); + assert_eq!(response.status(), StatusCode::OK, "{uri} status"); + assert_eq!( + response + .headers() + .get(CACHE_CONTROL) + .and_then(|value| value.to_str().ok()), + Some("no-store"), + "{uri} cache policy" + ); + assert!(!response.headers().contains_key(ETAG), "{uri} omits ETag"); + let document: Value = serde_json::from_slice( + &to_bytes(response.into_body(), 1024 * 1024) + .await + .expect("metadata body reads"), + ) + .expect("metadata is JSON"); + let capability = document + .pointer(capability_pointer) + .and_then(Value::as_object) + .expect("visible capability is linked"); + for reference in [ + "schemaReference", + "semanticModelReference", + "contextReference", + "processingReference", + ] { + assert!( + capability.get(reference).and_then(Value::as_str).is_some(), + "{uri} exposes {reference}" + ); + } + assert!( + !capability.contains_key("classificationReference"), + "operator-only classification metadata stays undiscoverable" + ); + assert!( + capability["processingReference"] + .as_str() + .is_some_and(|reference| reference.ends_with( + "/v2/artifacts/assistance-enrolment--lookup-by-case-and-person-processing" + )), + "processing metadata link resolves to the mounted artifact identifier" + ); + } +} + +#[tokio::test] +async fn invalid_bearer_on_unknown_data_routes_is_audited_fail_closed() { + let sink = Arc::new(ControlledAuditSink::new(usize::MAX)); + let harness = ProjectHarness::open_with_audit( + "business-registry", + Some(Arc::clone(&sink) as Arc), + ) + .await; + for (method, uri) in [ + (Method::GET, "/v2/resources/unknown/records"), + (Method::GET, "/v2/resources/unknown/records/record"), + (Method::POST, "/v2/resources/unknown/lookups/unknown"), + ] { + let response = harness + .app + .clone() + .oneshot( + Request::builder() + .method(method) + .uri(uri) + .header(AUTHORIZATION, "Bearer malformed") + .body(Body::empty()) + .expect("unknown request builds"), + ) + .await + .expect("router responds"); + assert_problem_code( + response, + StatusCode::UNAUTHORIZED, + "auth.invalid_credential", + ) + .await; + } + assert_eq!( + sink.writes(), + 3, + "each invalid credential is refused in audit" + ); + + let failing_sink = Arc::new(ControlledAuditSink::new(1)); + let harness = ProjectHarness::open_with_audit( + "business-registry", + Some(Arc::clone(&failing_sink) as Arc), + ) + .await; + assert_problem_code( + harness + .app + .oneshot( + Request::builder() + .uri("/v2/resources/unknown/records") + .header(AUTHORIZATION, "Bearer malformed") + .body(Body::empty()) + .expect("unknown request builds"), + ) + .await + .expect("router responds"), + StatusCode::SERVICE_UNAVAILABLE, + "audit.unavailable", + ) + .await; + assert_eq!(failing_sink.writes(), 1); +} + +#[tokio::test] +async fn lookup_body_collection_obeys_the_request_deadline() { + let harness = ProjectHarness::open("social-assistance").await; + let journey: Journey = serde_norway::from_slice( + &fs::read(project_root("social-assistance").join("expected-http.yaml")) + .expect("journey reads"), + ) + .expect("journey parses"); + let step = journey + .steps + .iter() + .find(|step| step.id == "lookup-success") + .expect("protected lookup journey exists"); + let fixture_id = step + .authorization_fixture + .as_deref() + .expect("lookup has authorization fixture"); + let fixture = journey + .authorizations + .get(fixture_id) + .expect("authorization fixture resolves"); + let token = harness.token(fixture_id, fixture); + let pending = stream::pending::>(); + let request = Request::builder() + .method(Method::POST) + .uri(&step.request.path) + .header(AUTHORIZATION, format!("Bearer {token}")) + .header(CONTENT_TYPE, "application/json") + .body(Body::from_stream(pending)) + .expect("pending lookup request builds"); + + let response = tokio::time::timeout( + Duration::from_millis( + harness + .runtime + .limits + .request_timeout_milliseconds + .saturating_add(1_000), + ), + harness.app.oneshot(request), + ) + .await + .expect("router enforces its shorter request deadline") + .expect("router responds"); + assert_problem_code(response, StatusCode::GATEWAY_TIMEOUT, "internal.timeout").await; +} + +fn request_with_bearer( + harness: &ProjectHarness, + step: &JourneyStep, + authorizations: &BTreeMap, + token: &str, +) -> Request { + let mut request = harness.request(step, authorizations); + request.headers_mut().insert( + AUTHORIZATION, + format!("Bearer {token}").parse().expect("bearer header"), + ); + request +} + +async fn assert_problem_code(response: http::Response, status: StatusCode, code: &str) { + let body = response_body(response, status).await; + assert_eq!(body.get("code").and_then(Value::as_str), Some(code)); +} + +async fn response_body(response: http::Response, status: StatusCode) -> Value { + assert_eq!(response.status(), status); + let bytes = to_bytes(response.into_body(), 1024 * 1024) + .await + .expect("response body reads"); + serde_json::from_slice(&bytes).expect("response is JSON") +} + +fn assert_expectations( + project: &str, + step: &JourneyStep, + headers: &HeaderMap, + body: &[u8], + equivalence_classes: &mut BTreeMap, +) { + let label = format!("{project}/{}", step.id); + if step.expect.body_empty { + assert!(body.is_empty(), "{label} body must be empty"); + return; + } + let document: Value = serde_json::from_slice(body) + .unwrap_or_else(|error| panic!("{label} response must be JSON: {error}")); + if let Some(code) = &step.expect.code { + assert_eq!( + document.get("code").and_then(Value::as_str), + Some(code.as_str()), + "{label} code" + ); + } + if step.expect.route_absent { + assert_eq!( + document.get("code").and_then(Value::as_str), + Some("resource.not_found"), + "{label} absent route must not disclose operation state" + ); + } + if !step.expect.capability_patterns.is_empty() + || !step.expect.absent_capability_patterns.is_empty() + { + let patterns = document + .get("capabilities") + .and_then(Value::as_array) + .expect("capabilities array") + .iter() + .filter_map(|capability| { + Some(format!( + "{}.{}", + capability.get("family")?.as_str()?, + capability.get("pattern")?.as_str()? + )) + }) + .collect::>(); + for expected in &step.expect.capability_patterns { + assert!( + patterns.contains(expected), + "{label} missing capability {expected}" + ); + } + for absent in &step.expect.absent_capability_patterns { + assert!( + !patterns.contains(absent), + "{label} exposed forbidden capability {absent}" + ); + } + } + if let Some(count) = step.expect.item_count { + assert_eq!( + document + .get("items") + .and_then(Value::as_array) + .map(Vec::len), + Some(count), + "{label} item count" + ); + } + if let Some(expectation) = &step.expect.next_cursor { + let cursor = document.pointer("/pageInfo/nextCursor"); + match expectation.as_str() { + "non-null" => assert!( + cursor.is_some_and(|value| !value.is_null()), + "{label} cursor" + ), + "null" => assert!(cursor.is_some_and(Value::is_null), "{label} cursor"), + value => panic!("{label} has unsupported nextCursor expectation {value}"), + } + } + let records = response_records(&document); + if step.expect.registry_core_required { + assert!(!records.is_empty(), "{label} must contain a Record"); + for record in &records { + for key in [ + "registryIdentifier", + "recordIdentifier", + "revisionIdentifier", + "lifecycleState", + "schemaReference", + "semanticModelReference", + "authorityIdentifier", + "recordedAt", + "domainData", + ] { + assert!(record.get(key).is_some(), "{label} Record is missing {key}"); + } + } + } + if !step.expect.domain_data_keys.is_empty() { + assert!(!records.is_empty(), "{label} must contain domain data"); + let expected = step + .expect + .domain_data_keys + .iter() + .map(String::as_str) + .collect::>(); + for record in &records { + let actual = record + .get("domainData") + .and_then(Value::as_object) + .expect("domainData object") + .keys() + .map(String::as_str) + .collect::>(); + assert_eq!(actual, expected, "{label} disclosed domain properties"); + } + } + if let Some(identifier) = &step.expect.record_identifier { + assert_eq!( + document + .pointer("/data/recordIdentifier") + .and_then(Value::as_str), + Some(identifier.as_str()), + "{label} record identifier" + ); + } + if let Some(cache) = &step.expect.cache { + assert_eq!( + cache, "public-snapshot-revalidation", + "{label} cache expectation" + ); + assert_eq!( + headers + .get(CACHE_CONTROL) + .and_then(|value| value.to_str().ok()), + Some("public, no-cache"), + "{label} cache-control" + ); + assert!(headers.contains_key(ETAG), "{label} requires an ETag"); + assert_eq!( + headers.get(VARY).and_then(|value| value.to_str().ok()), + Some("Accept, Authorization"), + "{label} vary" + ); + } + let body_text = String::from_utf8_lossy(body); + for absent in &step.expect.absent_everywhere { + assert!( + !body_text.contains(absent), + "{label} disclosed forbidden term {absent}" + ); + } + if let Some(class) = &step.expect.equivalence_class { + let mut normalized = document; + normalized + .as_object_mut() + .expect("problem object") + .remove("traceId"); + if let Some(existing) = equivalence_classes.get(class) { + assert_eq!( + &normalized, existing, + "{label} changed equivalence-class problem" + ); + } else { + equivalence_classes.insert(class.clone(), normalized); + } + } +} + +fn normalized_records(document: &Value) -> Vec { + response_records(document) + .into_iter() + .map(|record| { + let mut record = record.clone(); + if let Some(object) = record.as_object_mut() { + object.remove("@id"); + } + record + }) + .collect() +} + +fn response_records(document: &Value) -> Vec<&Value> { + if let Some(record) = document.get("data") { + vec![record] + } else { + document + .get("items") + .and_then(Value::as_array) + .map_or_else(Vec::new, |items| items.iter().collect()) + } +} + +impl ProjectHarness { + async fn open(project: &str) -> Self { + Self::open_with_audit(project, None).await + } + + async fn open_with_audit(project: &str, sink: Option>) -> Self { + let root = project_root(project); + let contract = RegistryContract::parse_yaml( + &fs::read_to_string(root.join("registry.yaml")).expect("contract reads"), + ) + .expect("contract parses"); + let runtime = RelayRuntime::parse_yaml( + &fs::read_to_string(root.join("runtime.yaml")).expect("runtime reads"), + ) + .expect("runtime parses"); + let temp = tempfile::tempdir().expect("temporary project creates"); + let database = temp.path().join("fixture.sqlite"); + materialize_fixture( + &database, + &fs::read_to_string(root.join("fixture.sql")).expect("fixture SQL reads"), + ) + .expect("fixture materializes"); + + let captured = CapturedSnapshot::capture(&database).expect("fixture captures"); + let catalog = inspect_schema( + &DatabaseProfile::Snapshot(captured), + &InspectionLimits { + maximum_objects: 10_000, + maximum_sql_bytes: 8 * 1024 * 1024, + maximum_statement_steps: 1_000_000, + timeout: Duration::from_secs(5), + }, + ) + .expect("schema inspects"); + let observed_fingerprint = catalog.fingerprint.clone(); + let source_id = contract + .sources + .keys() + .next() + .expect("one source") + .to_owned(); + let observed = vec![ObservedSourceSchema { + source: source_id.clone(), + fingerprint: catalog.fingerprint, + views: catalog + .objects + .into_iter() + .filter(|object| object.kind == SchemaObjectKind::View) + .map(|object| ObservedView { + name: object.name, + columns: object + .columns + .into_iter() + .map(|column| ObservedColumn { + name: column.name, + declared_type: column.declared_type, + nullable: column.nullable, + primary_key: column.primary_key, + }) + .collect(), + }) + .collect(), + }]; + let governed = governed_files(&root, &contract); + let compiled = Arc::new( + compile_contract_with_governed_files( + &contract, + &observed, + CompileProfile::Production, + &governed, + ) + .unwrap_or_else(|report| { + panic!( + "{project} compilation failed (observed schema {observed_fingerprint}): {report:?}" + ) + }), + ); + let artifacts = Arc::new(generate_artifacts(&compiled).expect("artifacts generate")); + let sqlite = Arc::new( + SqliteRuntime::open( + &compiled, + &BTreeMap::from([( + source_id, + RuntimeSourceBinding { + path: database.clone(), + }, + )]), + SqliteRuntimeLimits { + request_timeout: Duration::from_millis( + runtime.limits.request_timeout_milliseconds, + ), + concurrent_queries: usize::try_from(runtime.limits.concurrent_queries) + .expect("query limit fits"), + }, + ) + .expect("SQLite runtime opens"), + ); + let sink: Arc = + sink.unwrap_or_else(|| Arc::new(JsonlFileSink::new(temp.path().join("audit.jsonl")))); + let chain = Arc::new( + ChainState::bootstrap_unkeyed_dev_only(sink.as_ref()) + .await + .expect("test audit chain starts"), + ); + let audit = RelayAudit::new(chain, sink); + + let (authenticator, idp) = if let Some(issuer) = runtime.authentication.issuer.as_ref() { + let idp = MockIdp::start().await; + let fetcher = Arc::new(JwksFetcher::new_with_fetch_url_policy( + idp.jwks_uri(), + JwksFetcherConfig::defaults(), + FetchUrlPolicy::dev(), + )); + fetcher.ensure_key_set().await.expect("fixture JWKS loads"); + let mut config = oidc_verifier_config(idp.issuer(), vec![issuer.audience.clone()]); + config.allowed_typ = vec!["at+jwt".into()]; + config.max_token_lifetime = Some(Duration::from_secs(3600)); + ( + Some(RelayAuthenticator::new( + Arc::new(TokenVerifier::new(config, fetcher)), + issuer.audience.clone(), + Duration::from_secs(30), + )), + Some(idp), + ) + } else { + (None, None) + }; + let metadata = ServiceMetadata { + authority: InstitutionMetadata { + identifier: contract.registry.authority.identifier.clone(), + name: contract.registry.authority.name.clone(), + }, + operator: contract + .registry + .operator + .as_ref() + .map(|operator| InstitutionMetadata { + identifier: operator.identifier.clone(), + name: operator.name.clone(), + }), + authoritative_scope: contract.registry.authoritative_scope.clone(), + alignment_targets: contract + .registry + .alignment_targets + .iter() + .map(|target| AlignmentMetadata { + name: target.name.clone(), + version: target.version.clone(), + status: target.status.clone(), + cfr_target: target.cfr_target.clone(), + }) + .collect(), + }; + let service = Arc::new(RelayService::new( + compiled, + artifacts, + sqlite, + authenticator, + audit, + runtime.cursor.as_ref().map(|_| { + Arc::new( + registry_relay_v2::cursor::CursorKey::new(vec![0x5a; 32]) + .expect("cursor key is valid"), + ) + }), + Duration::from_secs( + runtime + .cursor + .as_ref() + .map_or(300, |cursor| cursor.maximum_age_seconds), + ), + Duration::from_millis(runtime.limits.request_timeout_milliseconds), + runtime.quotas.as_ref().map(|quota| QuotaConfig { + requests_per_minute: quota.requests_per_minute, + burst: quota.burst, + }), + metadata, + )); + Self { + app: router(Arc::clone(&service)), + service, + contract, + runtime, + database, + idp, + _temp: temp, + } + } + + fn request( + &self, + step: &JourneyStep, + authorizations: &BTreeMap, + ) -> Request { + self.request_with_observations(step, authorizations, &BTreeMap::new(), &BTreeMap::new()) + } + + fn request_with_observations( + &self, + step: &JourneyStep, + authorizations: &BTreeMap, + response_documents: &BTreeMap, + etags: &BTreeMap, + ) -> Request { + let mut url = step.request.path.clone(); + if !step.request.query.is_empty() { + let mut serializer = url::form_urlencoded::Serializer::new(String::new()); + for (name, value) in &step.request.query { + let scalar = journey_query_value(value, response_documents).unwrap_or_else(|| { + panic!( + "journey {} query {name} must be scalar, got {value:?}", + step.id + ) + }); + serializer.append_pair(name, &scalar); + } + url.push('?'); + url.push_str(&serializer.finish()); + } + let method = step + .request + .method + .parse::() + .expect("journey method is valid"); + let body = step + .request + .body + .as_ref() + .map(|selectors| { + serde_json::to_vec(&json!({"selectors": selectors})).expect("body serializes") + }) + .unwrap_or_default(); + let mut request = Request::builder() + .method(method) + .uri(url) + .body(Body::from(body)) + .expect("request builds"); + if step.request.body.is_some() { + request.headers_mut().insert( + CONTENT_TYPE, + "application/json".parse().expect("content type"), + ); + } + for (name, value) in &step.request.headers { + let value = value + .strip_prefix("$etag:") + .map_or_else( + || value.as_str(), + |reference| { + etags + .get(reference) + .unwrap_or_else(|| panic!("referenced ETag {reference} exists")) + }, + ) + .parse::() + .expect("journey header value"); + request.headers_mut().insert( + name.parse::().expect("journey header name"), + value, + ); + } + if let Some(fixture) = step.authorization_fixture.as_deref() { + let definition = authorizations + .get(fixture) + .unwrap_or_else(|| panic!("authorization fixture {fixture} is declared")); + let token = self.token(fixture, definition); + request.headers_mut().insert( + AUTHORIZATION, + format!("Bearer {token}") + .parse() + .expect("authorization header"), + ); + } + request + } + + fn token(&self, fixture: &str, definition: &AuthorizationFixture) -> String { + let audience = &self + .runtime + .authentication + .issuer + .as_ref() + .expect("runtime has issuer") + .audience; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock is valid") + .as_secs(); + self.signed_token( + fixture, + definition, + audience, + now, + now, + now.saturating_add(900), + ) + } + + fn signed_token( + &self, + fixture: &str, + definition: &AuthorizationFixture, + audience: &str, + issued_at: u64, + not_before: u64, + expires_at: u64, + ) -> String { + self.signed_token_with_audience( + fixture, + definition, + json!(audience), + issued_at, + not_before, + expires_at, + ) + } + + fn signed_token_with_audience( + &self, + fixture: &str, + definition: &AuthorizationFixture, + audience: Value, + issued_at: u64, + not_before: u64, + expires_at: u64, + ) -> String { + let issuer = self.idp.as_ref().expect("protected project has an IdP"); + let mut claims = serde_json::Map::new(); + claims.insert("iss".into(), json!(issuer.issuer())); + claims.insert("aud".into(), audience); + claims.insert("sub".into(), json!(definition.principal)); + claims.insert( + "scope".into(), + json!(definition + .scopes + .iter() + .cloned() + .collect::>() + .join(" ")), + ); + claims.insert("iat".into(), json!(issued_at)); + claims.insert("nbf".into(), json!(not_before)); + claims.insert("exp".into(), json!(expires_at)); + claims.insert( + "jti".into(), + json!(format!("fixture-{fixture}-{issued_at}")), + ); + for (name, value) in &definition.claims { + claims.insert(name.clone(), json!(value)); + } + sign_ed25519_compact_jwt( + fixtures::ED25519_PRIVATE_JWK, + "at+jwt", + "registry-platform-testing-ed25519-1", + Value::Object(claims), + ) + } +} + +async fn assert_unready(harness: &ProjectHarness, project: &str, condition: &str) { + assert!( + !harness.service.is_ready().await, + "{project} must become unready when its source is {condition}" + ); + let response = harness + .app + .clone() + .oneshot( + Request::builder() + .uri("/ready") + .body(Body::empty()) + .expect("request builds"), + ) + .await + .expect("router responds"); + let document = response_body(response, StatusCode::SERVICE_UNAVAILABLE).await; + assert_eq!( + document.get("code").and_then(Value::as_str), + Some("service.not_ready") + ); + let wire = serde_json::to_string(&document).expect("problem serializes"); + for protected in [ + project, + condition, + "fixture.sqlite", + "readiness_schema_drift", + harness.database.to_string_lossy().as_ref(), + ] { + assert!( + !wire.contains(protected), + "readiness failure disclosed protected detail" + ); + } +} + +#[cfg(unix)] +fn make_writable(path: &Path) { + use std::os::unix::fs::PermissionsExt as _; + fs::set_permissions(path, fs::Permissions::from_mode(0o600)).expect("source becomes writable"); +} + +#[cfg(not(unix))] +fn make_writable(path: &Path) { + let mut permissions = fs::metadata(path).expect("source metadata").permissions(); + permissions.set_readonly(false); + fs::set_permissions(path, permissions).expect("source becomes writable"); +} + +fn make_read_only(path: &Path) { + let mut permissions = fs::metadata(path).expect("source metadata").permissions(); + permissions.set_readonly(true); + fs::set_permissions(path, permissions).expect("source becomes read-only"); +} + +fn governed_files(root: &Path, contract: &RegistryContract) -> GovernedFileSet { + let mut paths = BTreeSet::new(); + paths.insert(contract.registry.identifier_lifecycle_policy_ref.clone()); + paths.insert(contract.classifications.provenance_ref.clone()); + for alignment in &contract.semantics.alignments { + paths.insert(alignment.profile_ref.clone()); + } + for resource in &contract.resources { + paths.insert(resource.record_context.lifecycle_state.codelist.clone()); + for (_, property) in resource.properties.iter() { + if let Some(path) = &property.codelist { + paths.insert(path.clone()); + } + } + for processing in &resource.processing_descriptions { + paths.insert(processing.legal_basis_ref.clone()); + paths.insert(processing.dpv_profile_ref.clone()); + } + } + paths + .into_iter() + .map(|path| { + let content = fs::read(root.join(&path)) + .unwrap_or_else(|error| panic!("governed file {path} reads: {error}")); + (path, content) + }) + .collect() +} + +fn project_root(project: &str) -> PathBuf { + Path::new(ACCEPTANCE_ROOT).join(project) +} + +fn yaml_scalar(value: &Value) -> Option { + match value { + Value::String(value) => Some(value.clone()), + Value::Bool(value) => Some(value.to_string()), + Value::Number(value) => Some(value.to_string()), + Value::Null | Value::Array(_) | Value::Object(_) => None, + } +} + +fn journey_query_value( + value: &Value, + response_documents: &BTreeMap, +) -> Option { + match value { + Value::String(value) => value + .strip_prefix("$nextCursor:") + .map(|reference| { + response_documents + .get(reference)? + .pointer("/pageInfo/nextCursor")? + .as_str() + .map(str::to_owned) + }) + .unwrap_or_else(|| Some(value.clone())), + _ => yaml_scalar(value), + } +} diff --git a/crates/registry-relay-v2/tests/multi_resource_isolation.rs b/crates/registry-relay-v2/tests/multi_resource_isolation.rs new file mode 100644 index 000000000..33de94e93 --- /dev/null +++ b/crates/registry-relay-v2/tests/multi_resource_isolation.rs @@ -0,0 +1,1119 @@ +// SPDX-License-Identifier: Apache-2.0 + +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use axum::body::{to_bytes, Body}; +use http::header::{AUTHORIZATION, CONTENT_TYPE}; +use http::{Method, Request, StatusCode}; +use registry_platform_audit::{AuditChainHasher, AuditEnvelope, AuditError, AuditSink, ChainState}; +use registry_platform_httputil::FetchUrlPolicy; +use registry_platform_oidc::{JwksFetcher, JwksFetcherConfig, TokenVerifier}; +use registry_platform_sqlite::{ + inspect_schema, materialize_fixture, CapturedSnapshot, DatabaseProfile, InspectionLimits, + SchemaObjectKind, +}; +use registry_platform_testing::{ + fixtures, oidc_verifier_config, sign_ed25519_compact_jwt, MockIdp, +}; +use registry_relay_v2::artifacts::{generate_artifacts, ArtifactSet}; +use registry_relay_v2::audit::RelayAudit; +use registry_relay_v2::auth::RelayAuthenticator; +use registry_relay_v2::compiler::{compile_contract_with_governed_files, GovernedFileSet}; +use registry_relay_v2::contract::{RegistryContract, Visibility}; +use registry_relay_v2::model::{ + CompileProfile, CompiledAccess, CompiledRegistry, ObservedColumn, ObservedSourceSchema, + ObservedView, OperationKind, RowAuthoritySource, +}; +use registry_relay_v2::server::{ + router, AlignmentMetadata, InstitutionMetadata, QuotaConfig, RelayService, ServiceMetadata, +}; +use registry_relay_v2::sqlite_runtime::{RuntimeSourceBinding, SqliteRuntime, SqliteRuntimeLimits}; +use serde_json::{json, Value}; +use tempfile::TempDir; +use tower::ServiceExt as _; + +const REGISTRY_ID: &str = "urn:example:registry:synthetic-units"; +const SOURCE_ID: &str = "synthetic-source"; +const PUBLIC_RESOURCE: &str = "public-unit"; +const PROTECTED_RESOURCE: &str = "protected-unit"; + +const FIXTURE_SQL: &str = r#" +CREATE TABLE public_rows ( + unit_id TEXT PRIMARY KEY NOT NULL, + revision TEXT NOT NULL, + lifecycle TEXT NOT NULL, + recorded_at TEXT NOT NULL, + public_label TEXT NOT NULL +) STRICT; + +INSERT INTO public_rows VALUES +('shared-001', 'public-r1', 'ACTIVE', '2026-08-01T00:00:00Z', 'PUBLIC-CANARY'); + +CREATE TABLE protected_rows ( + unit_id TEXT PRIMARY KEY NOT NULL, + revision TEXT NOT NULL, + lifecycle TEXT NOT NULL, + recorded_at TEXT NOT NULL, + protected_label TEXT NOT NULL, + lookup_key TEXT NOT NULL, + authority_key TEXT NOT NULL +) STRICT; + +INSERT INTO protected_rows VALUES +('shared-001', 'protected-r1', 'ACTIVE', '2026-08-02T00:00:00Z', 'PROTECTED-CANARY-A1', 'lookup-a1', 'zone-a'), +('protected-002', 'protected-r2', 'ACTIVE', '2026-08-03T00:00:00Z', 'PROTECTED-CANARY-A2', 'lookup-a2', 'zone-a'), +('protected-003', 'protected-r3', 'ACTIVE', '2026-08-04T00:00:00Z', 'PROTECTED-CANARY-B1', 'lookup-b1', 'zone-b'); + +CREATE VIEW relay_public_units AS +SELECT unit_id, revision, lifecycle, recorded_at, public_label +FROM public_rows; + +CREATE VIEW relay_protected_units AS +SELECT unit_id, revision, lifecycle, recorded_at, protected_label, lookup_key, authority_key +FROM protected_rows; +"#; + +const CONTRACT_YAML: &str = r#" +apiVersion: relay.registrystack.org/v2alpha1 +kind: RegistryContract +metadata: + id: synthetic-related-units + version: "1" + title: Synthetic related units +registry: + registryIdentifier: urn:example:registry:synthetic-units + name: Synthetic unit Registry + authority: {identifier: urn:example:institution:unit-authority, name: Unit Authority} + operator: {identifier: urn:example:institution:unit-operator, name: Unit Operator} + authoritativeScope: Synthetic related units used to prove resource isolation + baseUri: https://units.example.invalid/registry/ + identifierLifecyclePolicyRef: governance/identifier-lifecycle.yaml + alignmentTargets: + - name: synthetic-registry-profile + version: "1" + status: directional +governance: + controller: urn:example:institution:unit-controller + publisher: urn:example:institution:unit-publisher + auditOwner: urn:example:institution:unit-audit +semantics: + localVocabulary: https://units.example.invalid/vocabulary/ + alignments: [] +classifications: + privacy: {scheme: https://example.invalid/privacy, version: "1"} + institutional: {scheme: https://example.invalid/institutional, version: "1"} + handling: {scheme: https://id.registrystack.org/vocab/handling, version: "1"} + provenanceRef: governance/classification-provenance.yaml +sources: + synthetic-source: + kind: sqlite + profile: snapshot + expectedSchemaFingerprint: OBSERVED_FINGERPRINT +resources: + - id: public-unit + title: Public unit + description: Public projection of a synthetic related unit. + semanticClass: local:PublicUnit + source: {source: synthetic-source, view: relay_public_units} + classificationDefaults: {privacy: non-personal, institutional: public, handling: public, status: reviewed} + recordContext: + recordIdentifier: {sourceColumn: unit_id} + revisionIdentifier: {sourceColumn: revision} + lifecycleState: {sourceColumn: lifecycle, codelist: codelists/lifecycle.yaml} + recordedAt: {sourceColumn: recorded_at} + properties: + publicIdentifier: + sourceColumn: unit_id + type: string + sourceRequired: true + semanticTerm: local:publicIdentifier + label: Public identifier + description: Stable public unit identifier. + publicLabel: + sourceColumn: public_label + type: string + sourceRequired: true + semanticTerm: local:publicLabel + label: Public label + description: Public synthetic label. + disclosureProfiles: + public-view: {properties: [publicLabel]} + operations: + list: + access: public + disclosureProfile: public-view + filters: [] + allowUnfiltered: true + orderBy: [publicIdentifier] + pagination: {defaultPageSize: 1, maximumPageSize: 1} + read: {access: public, disclosureProfile: public-view} + processingDescriptions: [] + - id: protected-unit + title: Protected unit + description: Protected projection of a synthetic related unit. + semanticClass: local:ProtectedUnit + source: {source: synthetic-source, view: relay_protected_units} + classificationDefaults: {privacy: non-personal, institutional: internal, handling: internal, status: reviewed} + sourceColumnClassifications: + lookup_key: {privacy: non-personal} + authority_key: {privacy: non-personal} + recordContext: + recordIdentifier: {sourceColumn: unit_id} + revisionIdentifier: {sourceColumn: revision} + lifecycleState: {sourceColumn: lifecycle, codelist: codelists/lifecycle.yaml} + recordedAt: {sourceColumn: recorded_at} + properties: + protectedIdentifier: + sourceColumn: unit_id + type: string + sourceRequired: true + semanticTerm: local:protectedIdentifier + label: Protected identifier + description: Stable protected unit identifier. + protectedLabel: + sourceColumn: protected_label + type: string + sourceRequired: true + semanticTerm: local:protectedLabel + label: Protected label + description: Protected synthetic label. + disclosureProfiles: + protected-view: {properties: [protectedLabel]} + operations: + list: + access: + scope: relay:protected:list + purpose: {claim: purpose, allowed: [bounded-read]} + authorityRowBinding: {claim: authority, sourceColumn: authority_key} + disclosureProfile: protected-view + filters: [] + allowUnfiltered: true + orderBy: [protectedIdentifier] + pagination: {defaultPageSize: 2, maximumPageSize: 2} + read: + access: + scope: relay:protected:read + purpose: {claim: purpose, allowed: [bounded-read]} + authorityRowBinding: {claim: authority, sourceColumn: authority_key} + disclosureProfile: protected-view + lookups: + - id: by-key + access: + scope: relay:protected:lookup + purpose: {claim: purpose, allowed: [bounded-read]} + authorityRowBinding: {claim: authority, sourceColumn: authority_key} + requestBody: + maximumBytes: 128 + selectors: + lookupKey: {sourceColumn: lookup_key, type: string, minimumBytes: 1, maximumBytes: 32} + disclosureProfile: protected-view + processingDescriptions: + - id: protected-consultation + operationRefs: [list, read, lookup:by-key] + purpose: bounded-read + recipientClass: authorized-service + legalBasisRef: governance/legal-basis.yaml + dpvProfileRef: governance/processing.dpv.yaml + safeguards: [property-minimization, authority-row-binding] +metadataVisibility: + service: public + resources: public + semantics: public + classifications: operator-only + processing: operator-only +"#; + +struct Fixture { + _temp: TempDir, + database: std::path::PathBuf, + contract: RegistryContract, + compiled: Arc, + artifacts: Arc, +} + +#[derive(Default)] +struct RecordingAuditSink { + envelopes: Mutex>, +} + +impl RecordingAuditSink { + fn records(&self) -> Vec { + self.envelopes + .lock() + .expect("audit recorder lock") + .iter() + .map(|envelope| envelope.record.clone()) + .collect() + } +} + +#[async_trait::async_trait] +impl AuditSink for RecordingAuditSink { + async fn write(&self, envelope: &AuditEnvelope) -> Result<(), AuditError> { + self.envelopes + .lock() + .expect("audit recorder lock") + .push(envelope.clone()); + Ok(()) + } + + #[allow(deprecated)] + async fn tail_hash(&self) -> Result, AuditError> { + Ok(self + .envelopes + .lock() + .expect("audit recorder lock") + .last() + .map(|envelope| envelope.record_hash)) + } + + async fn tail_hash_with_hasher( + &self, + _hasher: &AuditChainHasher, + ) -> Result, AuditError> { + Ok(self + .envelopes + .lock() + .expect("audit recorder lock") + .last() + .map(|envelope| envelope.record_hash)) + } +} + +#[test] +fn compiler_keeps_every_multi_resource_operation_boundary_local() { + let fixture = compile_fixture(); + assert_eq!(fixture.compiled.resources.len(), 2); + + let cases = [ + ( + PUBLIC_RESOURCE, + "relay_public_units", + "public-view", + "publicLabel", + 1, + None, + None, + ), + ( + PROTECTED_RESOURCE, + "relay_protected_units", + "protected-view", + "protectedLabel", + 2, + Some("relay:protected:list"), + Some("authority_key"), + ), + ]; + for (resource_id, view, disclosure, field, page_maximum, scope, row_column) in cases { + let resource = resource(&fixture.compiled, resource_id); + assert_eq!(resource.source, SOURCE_ID); + assert_eq!(resource.view, view); + let list = operation(resource, "list"); + assert_eq!(list.identifier, format!("{resource_id}.list")); + assert_eq!(list.query.source, SOURCE_ID); + assert_eq!(list.query.view, view); + assert_eq!(list.disclosure_profile, disclosure); + assert_eq!(list.selectable_properties, [field]); + assert_eq!( + list.query + .pagination + .as_ref() + .expect("list has pagination") + .maximum_page_size, + page_maximum + ); + match (&list.access, scope, row_column) { + (CompiledAccess::Public, None, None) => {} + ( + CompiledAccess::Protected { + scope: actual_scope, + row_binding: Some(binding), + .. + }, + Some(expected_scope), + Some(expected_column), + ) => { + assert_eq!(actual_scope, expected_scope); + assert_eq!(binding.source_column, expected_column); + assert!( + matches!(binding.source, RowAuthoritySource::Claim(ref claim) if claim == "authority") + ); + } + boundary => panic!("unexpected compiled access boundary: {boundary:?}"), + } + assert!(list.schema_reference.contains(resource_id)); + assert!(list.semantic_model_reference.contains(resource_id)); + } + + let protected = resource(&fixture.compiled, PROTECTED_RESOURCE); + let lookup = operation(protected, "lookup"); + assert_eq!(lookup.identifier, "protected-unit.lookup.by-key"); + assert_eq!(lookup.query.view, "relay_protected_units"); + assert_eq!(lookup.query.maximum_request_body_bytes, Some(128)); + assert_eq!( + lookup + .query + .selectors + .iter() + .map(|selector| selector.name.as_str()) + .collect::>(), + ["lookupKey"] + ); + + let public_capabilities = artifact_json(&fixture.artifacts, "artifacts/capabilities.json"); + let full_capabilities = artifact_json(&fixture.artifacts, "artifacts/capabilities.full.json"); + assert_eq!( + capability_ids(&public_capabilities), + BTreeSet::from(["public-unit.list", "public-unit.read"]) + ); + assert_eq!( + capability_ids(&full_capabilities), + BTreeSet::from([ + "protected-unit.list", + "protected-unit.lookup.by-key", + "protected-unit.read", + "public-unit.list", + "public-unit.read", + ]) + ); + + let public_openapi = artifact_text(&fixture.artifacts, "openapi.public.json"); + let full_openapi = artifact_text(&fixture.artifacts, "openapi.full.yaml"); + assert!(public_openapi.contains("/v2/resources/public-unit/records")); + assert!(!public_openapi.contains("protected-unit")); + assert!(!public_openapi.contains("lookupKey")); + assert!(full_openapi.contains("/v2/resources/protected-unit/records")); + assert!(full_openapi.contains("lookupKey")); + + for artifact in fixture.artifacts.artifacts.iter().filter(|artifact| { + (artifact.id.starts_with("protected-unit-") || artifact.id.starts_with("protected-unit.")) + && (artifact.id.ends_with("-capability") + || artifact.id.ends_with("-classifications") + || artifact.id.ends_with("-processing")) + }) { + assert_ne!( + artifact.visibility, + Visibility::Public, + "a public sibling must not make protected resource artifact {} public", + artifact.id + ); + } +} + +#[tokio::test] +async fn real_router_keeps_related_public_and_protected_resources_isolated() { + let fixture = compile_fixture(); + let sink = Arc::new(RecordingAuditSink::default()); + let chain = Arc::new( + ChainState::bootstrap_unkeyed_dev_only(sink.as_ref()) + .await + .expect("audit chain starts"), + ); + let idp = MockIdp::start().await; + let fetcher = Arc::new(JwksFetcher::new_with_fetch_url_policy( + idp.jwks_uri(), + JwksFetcherConfig::defaults(), + FetchUrlPolicy::dev(), + )); + fetcher.ensure_key_set().await.expect("fixture JWKS loads"); + let audience = "urn:example:relay:synthetic-units"; + let mut verifier = oidc_verifier_config(idp.issuer(), vec![audience.into()]); + verifier.allowed_typ = vec!["at+jwt".into()]; + verifier.max_token_lifetime = Some(Duration::from_secs(3600)); + let authenticator = RelayAuthenticator::new( + Arc::new(TokenVerifier::new(verifier, fetcher)), + audience.into(), + Duration::from_secs(30), + ); + let sqlite = Arc::new( + SqliteRuntime::open( + &fixture.compiled, + &BTreeMap::from([( + SOURCE_ID.to_owned(), + RuntimeSourceBinding { + path: fixture.database.clone(), + }, + )]), + SqliteRuntimeLimits { + request_timeout: Duration::from_secs(5), + concurrent_queries: 2, + }, + ) + .expect("SQLite runtime opens"), + ); + let service = Arc::new(RelayService::new( + Arc::clone(&fixture.compiled), + Arc::clone(&fixture.artifacts), + sqlite, + Some(authenticator), + RelayAudit::new(chain, sink.clone()), + None, + Duration::from_secs(300), + Duration::from_secs(5), + Some(QuotaConfig { + requests_per_minute: 1, + burst: 1, + }), + ServiceMetadata { + authority: InstitutionMetadata { + identifier: fixture.contract.registry.authority.identifier.clone(), + name: fixture.contract.registry.authority.name.clone(), + }, + operator: fixture.contract.registry.operator.as_ref().map(|operator| { + InstitutionMetadata { + identifier: operator.identifier.clone(), + name: operator.name.clone(), + } + }), + authoritative_scope: fixture.contract.registry.authoritative_scope.clone(), + alignment_targets: fixture + .contract + .registry + .alignment_targets + .iter() + .map(|target| AlignmentMetadata { + name: target.name.clone(), + version: target.version.clone(), + status: target.status.clone(), + cfr_target: target.cfr_target.clone(), + }) + .collect(), + }, + )); + let app = router(service); + + let all_scopes = BTreeSet::from([ + "relay:protected:list", + "relay:protected:lookup", + "relay:protected:read", + ]); + let allowed = token( + &idp, + audience, + "allowed", + all_scopes.clone(), + [("purpose", "bounded-read"), ("authority", "zone-a")], + ); + let wrong_scope = token( + &idp, + audience, + "wrong-scope", + BTreeSet::from(["relay:protected:read"]), + [("purpose", "bounded-read"), ("authority", "zone-a")], + ); + let missing_binding = token( + &idp, + audience, + "missing-binding", + all_scopes, + [("purpose", "bounded-read")], + ); + + assert_problem( + send( + &app, + Method::GET, + "/v2/resources/public-unit/records?pageSize=2", + None, + None, + "00000000000000000000000000000001", + ) + .await, + StatusCode::BAD_REQUEST, + "consultation.invalid_request", + ); + assert_problem( + send( + &app, + Method::GET, + "/v2/resources/protected-unit/records?pageSize=2", + None, + None, + "00000000000000000000000000000002", + ) + .await, + StatusCode::UNAUTHORIZED, + "auth.missing_credential", + ); + assert_problem( + send( + &app, + Method::GET, + "/v2/resources/protected-unit/records?pageSize=2", + Some(&wrong_scope), + None, + "00000000000000000000000000000003", + ) + .await, + StatusCode::FORBIDDEN, + "consultation.denied", + ); + assert_problem( + send( + &app, + Method::GET, + "/v2/resources/protected-unit/records?pageSize=2", + Some(&missing_binding), + None, + "00000000000000000000000000000004", + ) + .await, + StatusCode::FORBIDDEN, + "consultation.denied", + ); + + let public_read = assert_success( + send( + &app, + Method::GET, + "/v2/resources/public-unit/records/shared-001", + None, + None, + "00000000000000000000000000000005", + ) + .await, + ); + assert_record_state( + &public_read, + "public-unit.read", + "public-view", + "publicLabel", + "PUBLIC-CANARY", + ); + assert!(!public_read.to_string().contains("PROTECTED-CANARY")); + + assert_problem( + send( + &app, + Method::GET, + "/v2/resources/protected-unit/records/shared-001", + None, + None, + "00000000000000000000000000000006", + ) + .await, + StatusCode::UNAUTHORIZED, + "auth.missing_credential", + ); + let protected_read = assert_success( + send( + &app, + Method::GET, + "/v2/resources/protected-unit/records/shared-001", + Some(&allowed), + None, + "00000000000000000000000000000007", + ) + .await, + ); + assert_record_state( + &protected_read, + "protected-unit.read", + "protected-view", + "protectedLabel", + "PROTECTED-CANARY-A1", + ); + assert!(!protected_read.to_string().contains("PUBLIC-CANARY")); + + let protected_list = assert_success( + send( + &app, + Method::GET, + "/v2/resources/protected-unit/records?pageSize=2", + Some(&allowed), + None, + "00000000000000000000000000000008", + ) + .await, + ); + let items = protected_list["items"].as_array().expect("list items"); + assert_eq!(items.len(), 2); + assert!(items.iter().all(|item| { + item["domainData"] + .get("protectedLabel") + .and_then(Value::as_str) + .is_some_and(|value| value.contains("CANARY-A")) + })); + assert!(!protected_list.to_string().contains("CANARY-B")); + + assert_problem( + send( + &app, + Method::GET, + "/v2/resources/protected-unit/records/shared-001?fields=publicLabel", + Some(&allowed), + None, + "00000000000000000000000000000009", + ) + .await, + StatusCode::BAD_REQUEST, + "request.fields_invalid", + ); + assert_problem( + send( + &app, + Method::POST, + "/v2/resources/public-unit/lookups/by-key", + Some(&allowed), + Some(json!({"selectors": {"lookupKey": "lookup-a1"}})), + "0000000000000000000000000000000a", + ) + .await, + StatusCode::NOT_FOUND, + "resource.not_found", + ); + let lookup = assert_success( + send( + &app, + Method::POST, + "/v2/resources/protected-unit/lookups/by-key", + Some(&allowed), + Some(json!({"selectors": {"lookupKey": "lookup-a1"}})), + "0000000000000000000000000000000b", + ) + .await, + ); + assert_record_state( + &lookup, + "protected-unit.lookup.by-key", + "protected-view", + "protectedLabel", + "PROTECTED-CANARY-A1", + ); + assert_problem( + send_raw_body( + &app, + Method::POST, + "/v2/resources/protected-unit/lookups/by-key", + Some(&allowed), + b"not-json", + "0000000000000000000000000000000d", + ) + .await, + StatusCode::TOO_MANY_REQUESTS, + "consultation.rate_limited", + ); + let independent_public_list = assert_success( + send( + &app, + Method::GET, + "/v2/resources/public-unit/records?pageSize=1", + None, + None, + "0000000000000000000000000000000e", + ) + .await, + ); + assert_eq!( + independent_public_list["items"] + .as_array() + .expect("public list items") + .len(), + 1, + "one exhausted protected lookup bucket cannot starve another resource operation" + ); + + let metadata = assert_success( + send( + &app, + Method::GET, + "/v2", + None, + None, + "0000000000000000000000000000000c", + ) + .await, + ); + assert_eq!(metadata["registryIdentifier"], REGISTRY_ID); + assert_eq!( + metadata["capabilities"] + .as_array() + .expect("service capabilities") + .iter() + .filter_map(|capability| capability["operationIdentifier"].as_str()) + .collect::>(), + BTreeSet::from(["public-unit.list", "public-unit.read"]) + ); + assert!(!metadata.to_string().contains(PROTECTED_RESOURCE)); + + let audits = sink.records(); + assert_audit_boundary( + &audits, + "00000000000000000000000000000005", + PUBLIC_RESOURCE, + "public-unit.read", + "public-view", + "none", + "publicLabel", + ); + assert_audit_boundary( + &audits, + "00000000000000000000000000000007", + PROTECTED_RESOURCE, + "protected-unit.read", + "protected-view", + "verified-claim", + "protectedLabel", + ); + assert_audit_boundary( + &audits, + "00000000000000000000000000000008", + PROTECTED_RESOURCE, + "protected-unit.list", + "protected-view", + "verified-claim", + "protectedLabel", + ); + assert_audit_boundary( + &audits, + "0000000000000000000000000000000b", + PROTECTED_RESOURCE, + "protected-unit.lookup.by-key", + "protected-view", + "verified-claim", + "protectedLabel", + ); + let audit_text = serde_json::to_string(&audits).expect("audits serialize"); + for absent in [ + "PUBLIC-CANARY", + "PROTECTED-CANARY", + "lookup-a1", + "zone-a", + "synthetic-caller", + ] { + assert!(!audit_text.contains(absent), "audit disclosed {absent}"); + } + + idp.stop().await; +} + +fn compile_fixture() -> Fixture { + let temp = tempfile::tempdir().expect("temporary fixture directory"); + let database = temp.path().join("multi-resource.sqlite"); + materialize_fixture(&database, FIXTURE_SQL).expect("fixture database materializes"); + let captured = CapturedSnapshot::capture(&database).expect("fixture snapshot captures"); + let catalog = inspect_schema( + &DatabaseProfile::Snapshot(captured), + &InspectionLimits { + maximum_objects: 100, + maximum_sql_bytes: 128 * 1024, + maximum_statement_steps: 100_000, + timeout: Duration::from_secs(5), + }, + ) + .expect("fixture schema inspects"); + let contract_text = CONTRACT_YAML.replace("OBSERVED_FINGERPRINT", &catalog.fingerprint); + let contract = RegistryContract::parse_yaml(&contract_text).expect("contract parses"); + let observed = vec![ObservedSourceSchema { + source: SOURCE_ID.into(), + fingerprint: catalog.fingerprint, + views: catalog + .objects + .into_iter() + .filter(|object| object.kind == SchemaObjectKind::View) + .map(|object| ObservedView { + name: object.name, + columns: object + .columns + .into_iter() + .map(|column| ObservedColumn { + name: column.name, + declared_type: column.declared_type, + nullable: column.nullable, + primary_key: column.primary_key, + }) + .collect(), + }) + .collect(), + }]; + let governed = GovernedFileSet::from([ + ( + "governance/identifier-lifecycle.yaml".into(), + b"kind: synthetic-policy\n".to_vec(), + ), + ( + "governance/classification-provenance.yaml".into(), + b"kind: synthetic-provenance\n".to_vec(), + ), + ( + "codelists/lifecycle.yaml".into(), + b"id: synthetic-lifecycle\nversion: '1'\nvalues: [ACTIVE]\nstatus: reviewed\n".to_vec(), + ), + ( + "governance/legal-basis.yaml".into(), + b"status: reviewed\nbasis: synthetic-authority\n".to_vec(), + ), + ( + "governance/processing.dpv.yaml".into(), + b"status: reviewed\nprofile: https://w3id.org/dpv/2.3\n".to_vec(), + ), + ]); + let compiled = Arc::new( + compile_contract_with_governed_files( + &contract, + &observed, + CompileProfile::Production, + &governed, + ) + .unwrap_or_else(|report| panic!("multi-resource contract compiles: {report:?}")), + ); + let artifacts = Arc::new(generate_artifacts(&compiled).expect("artifacts generate")); + Fixture { + _temp: temp, + database, + contract, + compiled, + artifacts, + } +} + +fn resource<'a>( + registry: &'a CompiledRegistry, + id: &str, +) -> &'a registry_relay_v2::model::CompiledResource { + registry + .resources + .iter() + .find(|resource| resource.id == id) + .unwrap_or_else(|| panic!("resource {id} is compiled")) +} + +fn operation<'a>( + resource: &'a registry_relay_v2::model::CompiledResource, + kind: &str, +) -> &'a registry_relay_v2::model::CompiledOperation { + resource + .operations + .iter() + .find(|operation| { + matches!( + (&operation.kind, kind), + (OperationKind::List, "list") + | (OperationKind::Read, "read") + | (OperationKind::Lookup { .. }, "lookup") + ) + }) + .unwrap_or_else(|| panic!("{kind} operation is compiled for {}", resource.id)) +} + +fn artifact_json(artifacts: &ArtifactSet, path: &str) -> Value { + serde_json::from_slice( + &artifacts + .get(path) + .unwrap_or_else(|| panic!("artifact {path} exists")) + .content, + ) + .unwrap_or_else(|error| panic!("artifact {path} is JSON: {error}")) +} + +fn artifact_text<'a>(artifacts: &'a ArtifactSet, path: &str) -> &'a str { + std::str::from_utf8( + &artifacts + .get(path) + .unwrap_or_else(|| panic!("artifact {path} exists")) + .content, + ) + .unwrap_or_else(|error| panic!("artifact {path} is UTF-8: {error}")) +} + +fn capability_ids(document: &Value) -> BTreeSet<&str> { + document["capabilities"] + .as_array() + .expect("capability array") + .iter() + .filter_map(|capability| capability["operationIdentifier"].as_str()) + .collect() +} + +fn token( + idp: &MockIdp, + audience: &str, + fixture: &str, + scopes: BTreeSet<&str>, + extra_claims: [(&str, &str); N], +) -> String { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock is valid") + .as_secs(); + let mut claims = serde_json::Map::new(); + claims.insert("iss".into(), json!(idp.issuer())); + claims.insert("aud".into(), json!(audience)); + claims.insert("sub".into(), json!("synthetic-caller")); + claims.insert( + "scope".into(), + json!(scopes.into_iter().collect::>().join(" ")), + ); + claims.insert("iat".into(), json!(now)); + claims.insert("nbf".into(), json!(now)); + claims.insert("exp".into(), json!(now + 900)); + claims.insert("jti".into(), json!(format!("fixture-{fixture}-{now}"))); + for (name, value) in extra_claims { + claims.insert(name.into(), json!(value)); + } + sign_ed25519_compact_jwt( + fixtures::ED25519_PRIVATE_JWK, + "at+jwt", + "registry-platform-testing-ed25519-1", + Value::Object(claims), + ) +} + +async fn send( + app: &axum::Router, + method: Method, + uri: &str, + bearer: Option<&str>, + body: Option, + trace_id: &str, +) -> (StatusCode, Value) { + let bytes = body + .as_ref() + .map(serde_json::to_vec) + .transpose() + .expect("request body serializes") + .unwrap_or_default(); + let mut request = Request::builder() + .method(method) + .uri(uri) + .header("traceparent", format!("00-{trace_id}-0000000000000001-01")) + .body(Body::from(bytes)) + .expect("request builds"); + if body.is_some() { + request.headers_mut().insert( + CONTENT_TYPE, + "application/json".parse().expect("content type header"), + ); + } + if let Some(token) = bearer { + request.headers_mut().insert( + AUTHORIZATION, + format!("Bearer {token}").parse().expect("bearer header"), + ); + } + let response = app.clone().oneshot(request).await.expect("router responds"); + let status = response.status(); + let bytes = to_bytes(response.into_body(), 1024 * 1024) + .await + .expect("response reads"); + let document = serde_json::from_slice(&bytes).unwrap_or_else(|error| { + panic!( + "response is JSON ({status}): {error}; body={}", + String::from_utf8_lossy(&bytes) + ) + }); + (status, document) +} + +async fn send_raw_body( + app: &axum::Router, + method: Method, + uri: &str, + bearer: Option<&str>, + body: &[u8], + trace_id: &str, +) -> (StatusCode, Value) { + let mut request = Request::builder() + .method(method) + .uri(uri) + .header("traceparent", format!("00-{trace_id}-0000000000000001-01")) + .header(CONTENT_TYPE, "application/json") + .body(Body::from(body.to_vec())) + .expect("request builds"); + if let Some(token) = bearer { + request.headers_mut().insert( + AUTHORIZATION, + format!("Bearer {token}").parse().expect("bearer header"), + ); + } + let response = app.clone().oneshot(request).await.expect("router responds"); + let status = response.status(); + let bytes = to_bytes(response.into_body(), 1024 * 1024) + .await + .expect("response reads"); + let document = serde_json::from_slice(&bytes).expect("response is JSON"); + (status, document) +} + +fn assert_problem(response: (StatusCode, Value), status: StatusCode, code: &str) { + assert_eq!(response.0, status, "problem body={}", response.1); + assert_eq!(response.1["code"], code); + let text = response.1.to_string(); + for absent in ["PUBLIC-CANARY", "PROTECTED-CANARY", "lookup-a1", "zone-a"] { + assert!(!text.contains(absent), "problem disclosed {absent}"); + } +} + +fn assert_success(response: (StatusCode, Value)) -> Value { + assert_eq!(response.0, StatusCode::OK, "response body={}", response.1); + response.1 +} + +fn assert_record_state( + document: &Value, + operation: &str, + disclosure: &str, + field: &str, + expected_value: &str, +) { + assert_eq!(document["data"]["registryIdentifier"], REGISTRY_ID); + assert_eq!(document["data"]["recordIdentifier"], "shared-001"); + assert_eq!(document["meta"]["operationIdentifier"], operation); + assert_eq!(document["meta"]["disclosureProfile"], disclosure); + assert_eq!(document["meta"]["selectedFields"], json!([field])); + assert_eq!( + document["data"]["domainData"], + json!({field: expected_value}) + ); + assert!(document["data"]["schemaReference"] + .as_str() + .is_some_and(|reference| reference.contains( + operation + .split('.') + .next() + .expect("operation identifier has a resource segment") + ))); +} + +fn assert_audit_boundary( + records: &[Value], + trace_id: &str, + resource: &str, + operation: &str, + disclosure: &str, + row_boundary: &str, + field: &str, +) { + let matching = records + .iter() + .filter(|record| record["traceId"] == trace_id) + .collect::>(); + assert_eq!( + matching.len(), + 2, + "attempt and terminal audit for {trace_id}" + ); + assert_eq!(matching[0]["phase"], "attempt"); + assert_eq!(matching[1]["phase"], "terminal"); + assert_eq!(matching[1]["outcome"], "released"); + for record in matching { + assert_eq!(record["registryIdentifier"], REGISTRY_ID); + assert_eq!(record["resourceIdentifier"], resource); + assert_eq!(record["operationIdentifier"], operation); + assert_eq!(record["disclosureProfile"], disclosure); + assert_eq!(record["rowBoundaryKind"], row_boundary); + assert_eq!( + record["processingDescriptionIdentifiers"], + if operation.starts_with("protected-unit.") { + json!(["protected-consultation"]) + } else { + json!([]) + } + ); + assert_eq!(record["selectedProperties"], json!([field])); + assert_eq!(record["sourceRevision"]["profile"], "snapshot"); + } +} diff --git a/crates/registry-relay-v2/tests/process_http.rs b/crates/registry-relay-v2/tests/process_http.rs new file mode 100644 index 000000000..59fd96b9f --- /dev/null +++ b/crates/registry-relay-v2/tests/process_http.rs @@ -0,0 +1,262 @@ +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(unix)] + +use std::fs; +use std::io::Read as _; +use std::net::TcpListener; +use std::os::unix::fs::PermissionsExt as _; +use std::path::Path; +use std::process::{Child, Command, Stdio}; +use std::time::{Duration, Instant}; + +use registry_platform_sqlite::materialize_fixture; +use registry_relay_v2::contract::RelayRuntime; +use registry_relay_v2::tooling::{package_project, PackageOptions}; +use reqwest::{Client, StatusCode}; +use serde_json::Value; + +const BUSINESS_PROJECT: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../products/relay-v2/acceptance/business-registry" +); + +struct RelayProcess { + child: Child, +} + +impl RelayProcess { + fn spawn(runtime: &Path) -> Self { + let child = Command::new(env!("CARGO_BIN_EXE_relay")) + .arg("serve") + .arg("--runtime") + .arg(runtime) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .spawn() + .expect("built relay process starts"); + Self { child } + } + + fn assert_running(&mut self) { + if let Some(status) = self.child.try_wait().expect("relay status reads") { + panic!( + "relay exited before accepting TCP requests with {status}: {}", + self.stderr() + ); + } + } + + async fn terminate_cleanly(mut self) { + let status = Command::new("kill") + .arg("-TERM") + .arg(self.child.id().to_string()) + .status() + .expect("SIGTERM command runs"); + assert!(status.success(), "SIGTERM reaches relay"); + let deadline = Instant::now() + Duration::from_secs(5); + loop { + if let Some(status) = self.child.try_wait().expect("relay status reads") { + assert!( + status.success(), + "relay did not shut down cleanly: {status}: {}", + self.stderr() + ); + return; + } + assert!( + Instant::now() < deadline, + "relay graceful shutdown exceeded its deadline" + ); + tokio::time::sleep(Duration::from_millis(25)).await; + } + } + + fn stderr(&mut self) -> String { + let mut output = String::new(); + if let Some(stderr) = &mut self.child.stderr { + let _ = stderr.read_to_string(&mut output); + } + output + } +} + +impl Drop for RelayProcess { + fn drop(&mut self) { + if self.child.try_wait().ok().flatten().is_none() { + let _ = self.child.kill(); + let _ = self.child.wait(); + } + } +} + +#[tokio::test] +async fn built_relay_serves_a_sealed_package_over_real_tcp_and_shuts_down() { + let temporary = tempfile::tempdir().expect("temporary image layout"); + let image_root = temporary + .path() + .canonicalize() + .expect("temporary image root canonicalizes"); + let etc = image_root.join("etc/relay"); + let data = image_root.join("var/lib/relay/data"); + let audit = image_root.join("var/lib/relay/audit"); + fs::create_dir_all(&etc).expect("runtime directory creates"); + fs::create_dir_all(&data).expect("data directory creates"); + fs::create_dir_all(&audit).expect("audit directory creates"); + fs::set_permissions(&audit, fs::Permissions::from_mode(0o700)) + .expect("audit directory becomes owner-only"); + copy_tree(Path::new(BUSINESS_PROJECT), &etc); + + let source = data.join("business-registry.sqlite"); + materialize_fixture( + &source, + &fs::read_to_string(etc.join("fixture.sql")).expect("fixture SQL reads"), + ) + .expect("fixture materializes"); + + let package = data.join("business-registry-package"); + let runtime_path = etc.join("runtime.yaml"); + let mut runtime = RelayRuntime::parse_yaml( + &fs::read_to_string(&runtime_path).expect("acceptance runtime reads"), + ) + .expect("acceptance runtime parses"); + let reservation = TcpListener::bind("127.0.0.1:0").expect("loopback port reserves"); + let address = reservation.local_addr().expect("reserved address"); + drop(reservation); + runtime.server.bind = address.to_string(); + runtime.package_path = package.to_string_lossy().into_owned(); + let mut runtime_value = serde_json::to_value(&runtime).expect("runtime becomes a value"); + *runtime_value + .pointer_mut("/sources/companies/path") + .expect("business source binding") = Value::String(source.to_string_lossy().into_owned()); + runtime = serde_json::from_value(runtime_value).expect("modified runtime remains valid"); + runtime.audit.sink = audit.join("events.jsonl").to_string_lossy().into_owned(); + runtime.audit.integrity_key_ref = "secret:file/audit-integrity-key".into(); + runtime + .cursor + .as_mut() + .expect("business cursor") + .integrity_key_ref = "secret:file/cursor-integrity-key".into(); + fs::write( + &runtime_path, + serde_norway::to_string(&runtime).expect("runtime serializes"), + ) + .expect("absolute runtime writes"); + write_secret( + &etc.join("audit-integrity-key"), + b"a-32-byte-minimum-synthetic-audit-key", + ); + write_secret( + &etc.join("cursor-integrity-key"), + b"a-32-byte-minimum-synthetic-cursor-key", + ); + + let report = package_project(&PackageOptions { + project_root: etc.clone(), + output_dir: package, + }) + .expect("sealed package operation succeeds"); + assert!(report.is_success(), "acceptance project packages"); + + let client = Client::builder() + .no_proxy() + .timeout(Duration::from_secs(1)) + .build() + .expect("HTTP client builds"); + let base = format!("http://{address}"); + let first = serve_one_lifecycle(&runtime_path, &client, &base).await; + let second = serve_one_lifecycle(&runtime_path, &client, &base).await; + assert_eq!( + first, second, + "the same sealed package and snapshot must serialize identically after restart" + ); +} + +async fn serve_one_lifecycle(runtime: &Path, client: &Client, base: &str) -> Vec { + let mut process = RelayProcess::spawn(runtime); + wait_until_ready(client, base, &mut process).await; + + let health = client + .get(format!("{base}/health")) + .send() + .await + .expect("health request completes"); + assert_eq!(health.status(), StatusCode::OK); + assert_eq!( + health.bytes().await.expect("health body reads"), + r#"{"status":"ok"}"# + ); + + let ready = client + .get(format!("{base}/ready")) + .send() + .await + .expect("readiness request completes"); + assert_eq!(ready.status(), StatusCode::OK); + assert_eq!( + ready.bytes().await.expect("readiness body reads"), + r#"{"status":"ready"}"# + ); + + let response = client + .get(format!( + "{base}/v2/resources/registered-business/records/BIZ-SYNTH-0001" + )) + .send() + .await + .expect("business request completes"); + assert_eq!(response.status(), StatusCode::OK); + let bytes = response.bytes().await.expect("business response reads"); + let document: Value = serde_json::from_slice(&bytes).expect("business response parses"); + assert_eq!( + document + .pointer("/data/recordIdentifier") + .and_then(Value::as_str), + Some("BIZ-SYNTH-0001") + ); + + process.terminate_cleanly().await; + bytes.to_vec() +} + +async fn wait_until_ready(client: &Client, base: &str, process: &mut RelayProcess) { + let deadline = Instant::now() + Duration::from_secs(10); + loop { + process.assert_running(); + if let Ok(response) = client.get(format!("{base}/ready")).send().await { + if response.status() == StatusCode::OK { + return; + } + } + assert!( + Instant::now() < deadline, + "relay did not become reachable on loopback" + ); + tokio::time::sleep(Duration::from_millis(50)).await; + } +} + +fn write_secret(path: &Path, bytes: &[u8]) { + fs::write(path, bytes).expect("secret writes"); + fs::set_permissions(path, fs::Permissions::from_mode(0o600)) + .expect("secret becomes owner-only"); +} + +fn copy_tree(source: &Path, destination: &Path) { + for entry in fs::read_dir(source).expect("acceptance project lists") { + let entry = entry.expect("acceptance entry reads"); + let target = destination.join(entry.file_name()); + let kind = entry.file_type().expect("acceptance entry type reads"); + if kind.is_dir() { + fs::create_dir(&target).expect("acceptance directory copies"); + copy_tree(&entry.path(), &target); + } else { + assert!( + kind.is_file(), + "acceptance closure contains only plain files" + ); + fs::copy(entry.path(), target).expect("acceptance file copies"); + } + } +} diff --git a/crates/registry-relayctl/Cargo.toml b/crates/registry-relayctl/Cargo.toml new file mode 100644 index 000000000..7a691b899 --- /dev/null +++ b/crates/registry-relayctl/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "registry-relayctl" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "Relay V2 adopter tooling and authoring workflows." +repository.workspace = true +publish = false +readme = "INTEGRATION.md" + +[[bin]] +name = "relayctl" +path = "src/main.rs" + +[lints] +workspace = true + +[dependencies] +clap.workspace = true +registry-platform-buildinfo.workspace = true +registry-relay-v2 = { workspace = true, features = ["tooling"] } +serde.workspace = true +serde_json.workspace = true diff --git a/crates/registry-relayctl/INTEGRATION.md b/crates/registry-relayctl/INTEGRATION.md new file mode 100644 index 000000000..93a8aedc2 --- /dev/null +++ b/crates/registry-relayctl/INTEGRATION.md @@ -0,0 +1,34 @@ +# Relay V2 tooling integration + +`registry-relayctl` owns command-line parsing and presentation only. Its single +semantic dependency seam is `src/shared.rs`, which calls +`registry_relay_v2::tooling` directly. The CLI never starts `relay`, parses +runtime output, opens SQLite itself, or classifies a contract change. + +The shared facade must provide: + +- `init_project(&InitOptions)` for a complete authoring workspace whose + compiler-derived semantics, classifications, processing metadata, and + lifecycle-policy suggestions are marked unreviewed; +- `inspect_schema(&InspectOptions)` through the Relay wrapper over + `registry-platform-sqlite`, returning structural metadata only; +- `check_project(&CheckOptions)`, including a production profile that refuses + every unreviewed suggestion; +- `generate_project`, `test_project`, `diff_projects`, and `package_project` + using the exact compiler, fixture, diff, and packager implementations shared + with the runtime; +- a serializable `ToolingReport` with typed status, `is_success()`, stable + value-free diagnostics, project-relative paths, and command-specific + `ToolingDetails`; +- a `ToolingError::safe_message()` that contains neither source values nor + absolute paths. + +`ToolingDetails::SchemaInspection` may contain object and column names, +declared SQLite types, nullability, key membership, object kind, and the schema +fingerprint. It must never contain row values, defaults evaluated from rows, or +SQL query results. `ToolingDetails::Diff` is the compiler's authoritative +change report. The CLI neither adds nor removes change classes. + +Workspace integration adds `registry-relay-v2` and `registry-relayctl` as root +members and workspace dependencies. That root edit and the corresponding lock +update are intentionally outside this crate's ownership. diff --git a/crates/registry-relayctl/src/lib.rs b/crates/registry-relayctl/src/lib.rs new file mode 100644 index 000000000..825508ce9 --- /dev/null +++ b/crates/registry-relayctl/src/lib.rs @@ -0,0 +1,332 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Thin adopter-facing command line for Relay V2. +//! +//! This crate owns argument parsing and report presentation. Contract parsing, +//! SQLite schema inspection, compilation, generation, fixture evaluation, +//! change classification, and packaging remain in `registry-relay-v2`. + +use std::ffi::OsString; +use std::io::{self, Write}; +use std::process::ExitCode; + +use clap::{Args, Parser, Subcommand}; +use serde::Serialize; + +mod shared; + +const DOMAIN_REFUSAL_EXIT: u8 = 1; +const USAGE_EXIT: u8 = 2; +const OPERATIONAL_FAILURE_EXIT: u8 = 3; + +#[derive(Debug, Parser)] +#[command( + name = "relayctl", + version = registry_platform_buildinfo::DISPLAY_VERSION, + about = "Relay V2 project authoring, validation, and packaging" +)] +pub struct Cli { + /// Emit the shared report as best-effort JSON for local automation. + #[arg(long, global = true)] + json: bool, + + #[command(subcommand)] + command: Command, +} + +#[derive(Debug, Subcommand)] +enum Command { + /// Initialize a complete authoring project with unreviewed starters. + Init(ProjectArg), + /// Inspect SQLite structure without reading row values. + Inspect(InspectArgs), + /// Compile and validate an authoring project. + Check(CheckArgs), + /// Generate deterministic artifacts from the compiled project. + Generate(OutputArgs), + /// Run the project's offline fixture cases through the shared kernel. + Test(TestArgs), + /// Classify meaning, disclosure, and security changes between projects. + Diff(DiffArgs), + /// Build a deterministic sealed deployment package. + Package(PackageArgs), +} + +#[derive(Debug, Args)] +struct ProjectArg { + /// Authoring project directory. + #[arg(value_name = "PROJECT")] + project: std::path::PathBuf, +} + +#[derive(Debug, Args)] +struct InspectArgs { + /// SQLite database to inspect structurally. + #[arg(value_name = "DATABASE")] + database: std::path::PathBuf, + + /// Write compiler-derived, visibly unreviewed starters to this directory. + #[arg(long, value_name = "DIRECTORY")] + starters: Option, +} + +#[derive(Debug, Args)] +struct CheckArgs { + /// Authoring project directory. + #[arg(value_name = "PROJECT")] + project: std::path::PathBuf, + + /// Require all generated suggestions to have been reviewed. + #[arg(long)] + production: bool, +} + +#[derive(Debug, Args)] +struct OutputArgs { + /// Authoring project directory. + #[arg(value_name = "PROJECT")] + project: std::path::PathBuf, + + /// Destination for generated artifacts. + #[arg(long, value_name = "DIRECTORY")] + output: Option, +} + +#[derive(Debug, Args)] +struct TestArgs { + /// Authoring project directory. + #[arg(value_name = "PROJECT")] + project: std::path::PathBuf, + + /// Run one exact fixture identifier. + #[arg(long, value_name = "IDENTIFIER")] + fixture: Option, +} + +#[derive(Debug, Args)] +struct DiffArgs { + /// Previously reviewed project directory. + #[arg(value_name = "PREVIOUS")] + previous: std::path::PathBuf, + + /// Candidate project directory. + #[arg(value_name = "CURRENT")] + current: std::path::PathBuf, +} + +#[derive(Debug, Args)] +struct PackageArgs { + /// Authoring project directory. + #[arg(value_name = "PROJECT")] + project: std::path::PathBuf, + + /// New sealed package directory. + #[arg(long, required = true, value_name = "DIRECTORY")] + output: std::path::PathBuf, +} + +/// Parse process arguments, run one shared-library operation, and return the +/// process exit status without exposing source values in errors. +pub fn main_entry() -> ExitCode { + run_from(std::env::args_os(), &mut io::stdout(), &mut io::stderr()) +} + +/// Testable command entry point. The operation itself is always delegated to +/// the shared Relay V2 tooling facade. +pub fn run_from(args: I, stdout: &mut dyn Write, stderr: &mut dyn Write) -> ExitCode +where + I: IntoIterator, + T: Into + Clone, +{ + let cli = match Cli::try_parse_from(args) { + Ok(cli) => cli, + Err(error) => { + let code = if error.use_stderr() { + ExitCode::from(u8::try_from(error.exit_code()).unwrap_or(USAGE_EXIT)) + } else { + ExitCode::SUCCESS + }; + if error.use_stderr() { + let _ = write!(stderr, "{error}"); + } else { + let _ = write!(stdout, "{error}"); + } + return code; + } + }; + + let command_name = cli.command.name(); + let report = match shared::execute(cli.command) { + Ok(report) => report, + Err(error) => { + let _ = writeln!(stderr, "relayctl: {}", error.safe_message()); + return ExitCode::from(OPERATIONAL_FAILURE_EXIT); + } + }; + + if render_report(command_name, &report, cli.json, stdout).is_err() { + let _ = writeln!(stderr, "relayctl: output could not be written"); + return ExitCode::from(OPERATIONAL_FAILURE_EXIT); + } + + if report.is_success() { + ExitCode::SUCCESS + } else { + ExitCode::from(DOMAIN_REFUSAL_EXIT) + } +} + +impl Command { + fn name(&self) -> &'static str { + match self { + Self::Init(_) => "init", + Self::Inspect(_) => "inspect", + Self::Check(_) => "check", + Self::Generate(_) => "generate", + Self::Test(_) => "test", + Self::Diff(_) => "diff", + Self::Package(_) => "package", + } + } +} + +fn render_report( + command: &str, + report: &T, + json: bool, + output: &mut dyn Write, +) -> io::Result<()> { + if json { + serde_json::to_writer_pretty(&mut *output, report).map_err(io::Error::other)?; + writeln!(output) + } else { + writeln!(output, "relayctl {command}")?; + // The shared report is the sole source of command details. Rendering + // it here does not reinterpret compiler outcomes or change classes. + serde_json::to_writer_pretty(&mut *output, report).map_err(io::Error::other)?; + writeln!(output) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_approved_command_is_present() { + for command in [ + "init", "inspect", "check", "generate", "test", "diff", "package", + ] { + let error = Cli::try_parse_from(["relayctl", command, "--help"]) + .expect_err("help stops parsing"); + assert_eq!(error.kind(), clap::error::ErrorKind::DisplayHelp); + } + } + + #[test] + fn inspect_has_no_value_sampling_option() { + let help = Cli::try_parse_from(["relayctl", "inspect", "--help"]) + .expect_err("help stops parsing") + .to_string(); + + assert!(help.contains("without reading row values")); + for forbidden in ["--sample", "--rows", "--values", "--limit"] { + assert!(!help.contains(forbidden), "unexpected option {forbidden}"); + } + } + + #[test] + fn package_requires_an_explicit_destination() { + let error = Cli::try_parse_from(["relayctl", "package", "project"]) + .expect_err("package destination is mandatory"); + assert_eq!( + error.kind(), + clap::error::ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn json_is_a_global_flag_before_or_after_the_subcommand() { + for arguments in [ + ["relayctl", "--json", "check", "project"], + ["relayctl", "check", "project", "--json"], + ] { + let cli = Cli::try_parse_from(arguments).expect("global JSON flag parses"); + assert!(cli.json); + } + } + + #[test] + fn command_line_usage_errors_do_not_enter_the_tooling_facade() { + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + + let status = run_from( + ["relayctl", "package", "authoring-project"], + &mut stdout, + &mut stderr, + ); + + assert_eq!(status, ExitCode::from(USAGE_EXIT)); + assert!(stdout.is_empty()); + let error = String::from_utf8(stderr).expect("clap error is UTF-8"); + assert!(error.contains("--output")); + assert!(!error.contains("selector")); + assert!(!error.contains("record")); + } + + #[test] + fn json_reports_are_one_valid_document() { + #[derive(Serialize)] + struct Report<'a> { + status: &'a str, + summary: &'a str, + } + + let mut output = Vec::new(); + render_report( + "inspect", + &Report { + status: "accepted", + summary: "schema structure inspected", + }, + true, + &mut output, + ) + .expect("report renders"); + + let value: serde_json::Value = serde_json::from_slice(&output).expect("valid JSON"); + assert_eq!(value["status"], "accepted"); + } + + #[test] + fn production_review_is_an_explicit_check_mode() { + let cli = Cli::try_parse_from(["relayctl", "check", "project", "--production"]) + .expect("production check parses"); + let Command::Check(args) = cli.command else { + panic!("check command is retained"); + }; + assert!(args.production); + } + + #[test] + fn json_rendering_is_deterministic_and_has_one_trailing_newline() { + #[derive(Serialize)] + struct Report<'a> { + status: &'a str, + summary: &'a str, + } + + let mut first = Vec::new(); + let mut second = Vec::new(); + let report = Report { + status: "accepted", + summary: "schema structure inspected", + }; + render_report("inspect", &report, true, &mut first).expect("report renders"); + render_report("inspect", &report, true, &mut second).expect("report repeats"); + + assert_eq!(first, second); + assert!(first.ends_with(b"\n")); + assert!(!first.ends_with(b"\n\n")); + } +} diff --git a/crates/registry-relayctl/src/main.rs b/crates/registry-relayctl/src/main.rs new file mode 100644 index 000000000..5df8a98a9 --- /dev/null +++ b/crates/registry-relayctl/src/main.rs @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: Apache-2.0 + +use std::process::ExitCode; + +fn main() -> ExitCode { + registry_relayctl::main_entry() +} diff --git a/crates/registry-relayctl/src/shared.rs b/crates/registry-relayctl/src/shared.rs new file mode 100644 index 000000000..57a349d13 --- /dev/null +++ b/crates/registry-relayctl/src/shared.rs @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: Apache-2.0 +//! The only dependency seam from adopter presentation into Relay semantics. + +use registry_relay_v2::tooling::{ + self, CheckOptions, DiffOptions, GenerateOptions, InitOptions, InspectOptions, PackageOptions, + TestOptions, ToolingError, ToolingReport, +}; + +use crate::Command; + +pub(crate) fn execute(command: Command) -> Result { + match command { + Command::Init(args) => tooling::init_project(&InitOptions { + project_root: args.project, + }), + Command::Inspect(args) => tooling::inspect_schema(&InspectOptions { + database_path: args.database, + starter_output: args.starters, + }), + Command::Check(args) => tooling::check_project(&CheckOptions { + project_root: args.project, + production: args.production, + }), + Command::Generate(args) => tooling::generate_project(&GenerateOptions { + project_root: args.project, + output_dir: args.output, + }), + Command::Test(args) => tooling::test_project(&TestOptions { + project_root: args.project, + fixture_id: args.fixture, + }), + Command::Diff(args) => tooling::diff_projects(&DiffOptions { + previous_root: args.previous, + current_root: args.current, + }), + Command::Package(args) => tooling::package_project(&PackageOptions { + project_root: args.project, + output_dir: args.output, + }), + } +} diff --git a/crates/registry-relayctl/tests/cli_contract.rs b/crates/registry-relayctl/tests/cli_contract.rs new file mode 100644 index 000000000..8a04f8558 --- /dev/null +++ b/crates/registry-relayctl/tests/cli_contract.rs @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: Apache-2.0 + +use std::process::Command; + +fn relayctl(arguments: &[&str]) -> std::process::Output { + Command::new(env!("CARGO_BIN_EXE_relayctl")) + .args(arguments) + .output() + .expect("relayctl starts") +} + +#[test] +fn the_adopter_workflow_is_exposed_by_one_binary() { + for command in [ + "init", "inspect", "check", "generate", "test", "diff", "package", + ] { + let output = relayctl(&[command, "--help"]); + assert!(output.status.success(), "{command} help failed"); + assert!(output.stderr.is_empty(), "{command} help used stderr"); + } +} + +#[test] +fn schema_inspection_offers_no_row_or_value_sampling_surface() { + let output = relayctl(&["inspect", "--help"]); + assert!(output.status.success()); + + let help = String::from_utf8(output.stdout).expect("help is UTF-8"); + assert!(help.contains("without reading row values")); + for forbidden in ["--sample", "--rows", "--values", "--limit"] { + assert!(!help.contains(forbidden), "unexpected option {forbidden}"); + } +} + +#[test] +fn package_refuses_an_implicit_destination_without_echoing_project_contents() { + let output = relayctl(&["package", "project"]); + assert_eq!(output.status.code(), Some(2)); + assert!(output.stdout.is_empty()); + + let error = String::from_utf8(output.stderr).expect("error is UTF-8"); + assert!(error.contains("--output")); + assert!(!error.contains("selector")); + assert!(!error.contains("record")); +} + +#[test] +fn adopter_commands_link_the_shared_library_and_never_spawn_relay() { + let library = include_str!("../src/lib.rs"); + let shared = include_str!("../src/shared.rs"); + let binary = include_str!("../src/main.rs"); + let production = format!("{library}\n{shared}\n{binary}"); + + assert!(shared.contains("registry_relay_v2::tooling")); + for forbidden in ["std::process::Command", "Command::new", "rusqlite"] { + assert!( + !production.contains(forbidden), + "tooling boundary contains {forbidden}" + ); + } +} diff --git a/products/platform/fuzz/Cargo.lock b/products/platform/fuzz/Cargo.lock index f2a9594c7..2e259179e 100644 --- a/products/platform/fuzz/Cargo.lock +++ b/products/platform/fuzz/Cargo.lock @@ -469,6 +469,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "ff" version = "0.13.1" @@ -1047,6 +1059,23 @@ dependencies = [ "cc", ] +[[package]] +name = "libsqlite3-sys" +version = "0.38.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6c19a05435c21ac299d71b6a9c13db3e3f47c520517d58990a462a1397a61db" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.2" @@ -1222,6 +1251,12 @@ dependencies = [ "spki", ] +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + [[package]] name = "portable-atomic" version = "1.13.1" @@ -1489,6 +1524,7 @@ dependencies = [ "registry-platform-crypto", "registry-platform-oidc", "registry-platform-sdjwt", + "registry-platform-sqlite", "serde", "serde_json", "serde_urlencoded", @@ -1550,6 +1586,18 @@ dependencies = [ "ulid", ] +[[package]] +name = "registry-platform-sqlite" +version = "0.18.0" +dependencies = [ + "rusqlite", + "rustix", + "serde", + "sha2 0.11.0", + "thiserror", + "tokio", +] + [[package]] name = "reqwest" version = "0.12.28" @@ -1619,6 +1667,19 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rusqlite" +version = "0.40.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11438310b19e3109b6446c33d1ed5e889428cf2e278407bc7896bc4aaea43323" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rustc-hash" version = "2.1.3" @@ -1634,6 +1695,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.41" @@ -2232,6 +2306,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" diff --git a/products/platform/fuzz/Cargo.toml b/products/platform/fuzz/Cargo.toml index e76296dd6..3c184f4be 100644 --- a/products/platform/fuzz/Cargo.toml +++ b/products/platform/fuzz/Cargo.toml @@ -17,6 +17,7 @@ registry-platform-authcommon = { path = "../../../crates/registry-platform-authc registry-platform-crypto = { path = "../../../crates/registry-platform-crypto" } registry-platform-oidc = { path = "../../../crates/registry-platform-oidc" } registry-platform-sdjwt = { path = "../../../crates/registry-platform-sdjwt" } +registry-platform-sqlite = { path = "../../../crates/registry-platform-sqlite" } serde = { version = "1", features = ["derive"] } serde_json = "1" serde_urlencoded = "0.7" @@ -43,3 +44,10 @@ path = "fuzz_targets/sdjwt_issuance.rs" test = false doc = false bench = false + +[[bin]] +name = "sqlite_statement" +path = "fuzz_targets/sqlite_statement.rs" +test = false +doc = false +bench = false diff --git a/products/platform/fuzz/README.md b/products/platform/fuzz/README.md index 29f17467c..208ee7175 100644 --- a/products/platform/fuzz/README.md +++ b/products/platform/fuzz/README.md @@ -12,6 +12,8 @@ crates. These live outside the main workspace (see the root `Cargo.toml` - `sdjwt_holder_proof` — SD-JWT holder-proof JWT verification (`registry-platform-sdjwt`). - `sdjwt_issuance` — SD-JWT issuance input parsing (`registry-platform-sdjwt`). +- `sqlite_statement` — bounded reviewed-statement parsing and offline + authorization (`registry-platform-sqlite`). Each target fuzzes the crate's real exported deserializer or entry point directly, never a locally re-declared mirror struct that could drift from the diff --git a/products/platform/fuzz/corpus/sqlite_statement/multiple-statements b/products/platform/fuzz/corpus/sqlite_statement/multiple-statements new file mode 100644 index 000000000..1700c9345 --- /dev/null +++ b/products/platform/fuzz/corpus/sqlite_statement/multiple-statements @@ -0,0 +1 @@ +SELECT 'first' AS result; ATTACH ':memory:' AS extra; diff --git a/products/platform/fuzz/corpus/sqlite_statement/positional-alias b/products/platform/fuzz/corpus/sqlite_statement/positional-alias new file mode 100644 index 000000000..dd0358b7b --- /dev/null +++ b/products/platform/fuzz/corpus/sqlite_statement/positional-alias @@ -0,0 +1 @@ +SELECT :value AS result, ?1 diff --git a/products/platform/fuzz/corpus/sqlite_statement/quoted-and-commented-parameters b/products/platform/fuzz/corpus/sqlite_statement/quoted-and-commented-parameters new file mode 100644 index 000000000..10c668419 --- /dev/null +++ b/products/platform/fuzz/corpus/sqlite_statement/quoted-and-commented-parameters @@ -0,0 +1,2 @@ +SELECT '?' AS result -- ?1 +/* ?2 */ diff --git a/products/platform/fuzz/corpus/sqlite_statement/select-valid b/products/platform/fuzz/corpus/sqlite_statement/select-valid new file mode 100644 index 000000000..6f0297d84 --- /dev/null +++ b/products/platform/fuzz/corpus/sqlite_statement/select-valid @@ -0,0 +1 @@ +SELECT 'ok' AS result diff --git a/products/platform/fuzz/fuzz_targets/sqlite_statement.rs b/products/platform/fuzz/fuzz_targets/sqlite_statement.rs new file mode 100644 index 000000000..43e7ff00b --- /dev/null +++ b/products/platform/fuzz/fuzz_targets/sqlite_statement.rs @@ -0,0 +1,37 @@ +#![no_main] + +use std::time::Duration; + +use libfuzzer_sys::fuzz_target; +use registry_platform_sqlite::{ + check_statement_offline, ColumnContract, ColumnType, ParameterContract, StatementContract, + StatementLimits, +}; + +fuzz_target!(|data: &[u8]| { + let Ok(input) = std::str::from_utf8(data) else { + return; + }; + let sql: String = input.chars().take(16_384).collect(); + let contract = StatementContract { + sql, + columns: vec![ColumnContract { + name: "result".to_owned(), + value_type: ColumnType::String, + }], + parameters: vec![ParameterContract { + name: "value".to_owned(), + required: false, + }], + limits: StatementLimits { + maximum_rows: 1, + maximum_cell_bytes: 1_024, + maximum_response_bytes: 1_024, + maximum_statement_steps: 10_000, + timeout: Duration::from_millis(100), + concurrency: 1, + }, + schema: None, + }; + let _ = check_statement_offline(&contract); +}); diff --git a/products/relay-v2/CONCEPT.md b/products/relay-v2/CONCEPT.md new file mode 100644 index 000000000..171dd3d2c --- /dev/null +++ b/products/relay-v2/CONCEPT.md @@ -0,0 +1,674 @@ +# Relay V2 Product Concept + +Status: Approved product direction +Date: 2026-08-09 +Basis: Registry Stack `origin/main` at the start of the Relay V2 exploration + +Directional inputs: the written GovStack Digital Registries specification and +API Design Guide as inspected on 2026-08-09. Both are early drafts that Relay +can help improve. They are design inputs, not conformance authorities. The +legacy Digital Registries OpenAPI is explicitly excluded because it does not +represent the current written model. + +## Executive position + +Relay V2 publishes one existing authoritative Registry as governed, +semantically interoperable, read-only registry resources. + +SQLite is the first source adapter, not the product identity. The product is the registry contract and the compiler that turns that contract into a controlled API, semantic and governance metadata, validation artifacts, and auditable runtime behavior. + +The concise promise is: + +> Give Relay an authoritative SQLite database and a registry contract. Relay produces a protected registry API whose meaning, disclosure, authorization, provenance, and documentation agree by construction. + +Relay V2 follows the method that made Evidence tractable: a narrow product boundary, startup-compiled trusted artifacts, deterministic runtime behavior, explicit security invariants, coequal acceptance fixtures, and generated contracts checked for drift. + +## What the product accepts and produces + +An adopter supplies: + +- an authoritative SQLite database or reviewed SQLite views; +- a small registry contract describing resources and their source bindings; +- deployment configuration for token issuers, authentication, audit, and limits; +- optional curated mappings to external vocabularies and privacy frameworks. + +Relay produces: + +- a read-only registry API over explicitly declared resources; +- ordinary JSON and equivalent JSON-LD representations; +- OpenAPI, JSON Schema, SHACL shapes, contexts, codelists, and discovery metadata; +- derived Consultation capabilities and a concise standards-alignment note; +- generated local semantics when the adopter has no existing vocabulary work; +- controlled disclosure, safe requester minimization, and access decisions; +- versioned provenance and value-free audit events; +- reproducible validation, fixture, and change-impact reports. + +All outputs derive from one compiled contract. Tables and columns never become public merely because they exist in SQLite. + +## Runtime and adopter tooling + +Relay V2 has an explicit runtime and tooling pair: + +| Component | Responsibility | +|---|---| +| `relay` | Authoritative contract compilation, validation, serving, access decisions, disclosure planning, artifact semantics, and fixture evaluation. | +| `relayctl` | Project scaffolding, SQLite inspection, semantic and classification starter generation, validation orchestration, artifact generation, fixture runs, change-impact reporting, and deployment packaging. | + +The new adopter-tooling crate is `registry-relayctl` and its binary is `relayctl`. + +`relayctl` and `relay` use the same Relay compiler and fixture library. `relayctl` +may own authoring-only workflows, but it must not invoke a second compiler, +parse human CLI output, or implement a second interpretation of Relay +semantics. A frozen subprocess protocol is unnecessary until a consumer other +than the in-tree tooling needs one. + +The existing `registryctl` is not renamed, migrated, deprecated, or otherwise changed as part of Relay V2. It remains outside this work. Relay V2 does not depend on it and provides no compatibility layer through it. + +The intended authoring lifecycle is: + +```text +init -> inspect -> check -> generate -> test -> diff -> package +``` + +Inspection is schema-only by default. Any future value sampling must be explicit, local, bounded, and value-free in its output. Generated semantics and classifications are suggestions until reviewed. + +## Core product features + +### Contract-first registry publication + +A Relay deployment describes exactly one Registry. The Registry has a globally +stable identifier, name, Registry Authority, optional operator, authoritative +scope, base URI, standards-alignment targets, resources, and operations. The +Registry Authority is accountable for the Registry in its declared scope. It +is not automatically the same party as the privacy controller, publisher, or +technical operator. + +A Relay resource is a governed Record type or collection within that Registry, +not a table and not a second Registry. It has a stable identity, semantic class, +identifier strategy, declared properties, a reviewed source view, an enumeration +posture, a disclosure profile, and an access rule. + +The source schema and public schema are deliberately separate. One table may support several resources, several tables may feed one reviewed view, and unconfigured database objects remain invisible. + +The compiler rejects incomplete or inconsistent contracts before serving. It also detects drift in live databases and never silently widens the public contract. + +### Native Registry Core record context + +Every returned Record carries a non-selectable core context: + +- `registryIdentifier`; +- `recordIdentifier`; +- `revisionIdentifier`; +- `lifecycleState`; +- `schemaReference`; +- `semanticModelReference`; +- `authorityIdentifier`; +- `recordedAt`; +- `domainData`, containing only the authorized and requested domain properties. + +The pair `(registryIdentifier, recordIdentifier)` identifies a Record. The +record identifier is stable and cannot be reassigned. A JSON-LD `@id` may be +derived as a global IRI, but it never replaces or changes the authoritative +record identifier. + +Record identifier, record revision, lifecycle state, and recorded time are +explicitly bound from the reviewed source view. Registry identity, authority, +schema, and semantic-model references come from the governed contract. +`recordedAt` is the authoritative revision-recorded time, never Relay startup, +snapshot, or response time. Source, contract, operation, disclosure, response, +and Record revisions remain distinct concepts. + +Relay can validate current identifiers and compare successive synthetic +snapshots, but a single current SQLite file cannot prove that an institution +has never reassigned an identifier. `relayctl` therefore records the reviewed +identifier lifecycle policy and can compare successive fixture snapshots as +alignment evidence. The institutional guarantee remains named as such. + +### Registry API families + +API families are external capability and trust groupings. They are not crates, +services, URL prefixes, or an invitation to implement every family. + +Relay V2 implements only the Consultation patterns actually compiled for a +Registry: + +- identifier read maps to `consultation.retrieve`; +- collection list maps to `consultation.list`; +- named exact lookup maps to a constrained `consultation.search` profile. + +Exact lookup is not Record Match. Relay returns no candidates, confidence, or +matching explanation. Family and pattern identifiers are attached to compiled +operations and generated into capability discovery. They are not repeated in +an independently authored capability list and do not appear in route names. + +Relay V2 does not claim Provisioning, Evidence, Write, Notification, Aggregate +Data, Access Transparency, or Identity Federation. `relayctl` is offline +authoring tooling, not a Provisioning API. Internal audit is not an Access +Transparency service. OAuth protection and optional Mint issuance are not +Identity Federation. Registry Evidence remains a separate product. + +The draft Digital Registries target is recorded as an `alignmentTarget`, and +the generated mapping is `alignmentEvidence`. Relay does not claim GovStack +certification, Digital Registries conformance, or the future Base Registry +profile. A deployment advertises only the Consultation patterns it actually +compiles. + +### Semantic by default, without an expert prerequisite + +Relay always has a basic semantic model, but adopters do not need JSON-LD or SHACL expertise to begin. + +Authoring tooling can generate a stable local vocabulary from the reviewed resource definition and SQLite schema: + +- local class and property IRIs; +- datatypes and cardinalities; +- identifier and codelist candidates; +- labels, descriptions, codelist candidates, JSON-LD context, SHACL, and JSON Schema. + +Generated semantics are marked as local and generated. They make no automatic claim of equivalence with SEMIC, PublicSchema, schema.org, or another external vocabulary. Curated mappings are optional, versioned, and state their relation strength, such as exact, close, broad, narrow, or related. + +The progression is therefore: + +1. generated local semantics; +2. curated local semantics; +3. optional alignment with external profiles. + +Relay uses semantics during compilation and serialization. They are not a decorative catalog attachment and are never fetched or inferred at runtime. + +### Classification and privacy processing + +Classification belongs primarily to the published property. Its SQLite column or view column is the source binding. This matters because published properties can be derived, renamed, combined, or reused in different disclosures. + +Every reviewed source-view column also has a technical handling classification. +A simple property binding inherits its property's handling unless the source is +explicitly more restrictive. Registry Core, selector, row-binding, revision, +filter, and order columns that are not published properties declare their own +classification. Hidden columns remain classified and auditable even though +they are never serialized. The compiler rejects an unclassified reviewed +column and applies the most restrictive source and output handling to the +operation. + +The model distinguishes: + +- domain meaning: what the property represents; +- privacy category: whether it is personal, identifying, sensitive, derived, or another governed category; +- institutional classification: the operator's own classification scheme; +- technical handling: the controls Relay must enforce. + +Classification entries carry a scheme, value, provenance, status, and version. Generated classifications are suggestions until reviewed. Unclassified published properties fail the production profile. + +A resource may declare reviewed classification defaults and properties or +hidden columns declare only exceptions. Defaults are expanded during +compilation, so the effective classification remains complete without +repeating the same four values on every column. + +Classification is monotonic for security: uncertainty or a more restrictive classification may reduce availability, but metadata changes never expand access automatically. + +The initial technical handling vocabulary is ordered: `public`, `internal`, +`confidential`, and `restricted`. `public` may be anonymously released only by +an explicitly public operation. Every non-public level requires authentication, +an operation scope, `no-store`, and durable value-free audit. `confidential` +and `restricted` also prevent public classification and processing metadata; +`restricted` cannot be exposed through collection listing. Purpose and +authority-to-row binding remain explicit reviewed access constraints rather +than being guessed from a classification label. The compiler applies the most +restrictive effective handling level across the selected Record properties and +rejects any weaker operation or metadata posture. + +[Data Privacy Vocabulary 2.3](https://w3c-cg.github.io/dpv/2.3/dpv/) is a strong optional governance profile. Domain vocabularies describe what a registry fact means; DPV describes why and how it is processed, the parties and recipients involved, the applicable purpose and legal context, and the technical or organisational measures associated with disclosure. + +Relay may generate an optional DPV starter from the registry contract and let +adopters curate it. A DPV document is a governance sidecar, not a prerequisite +for serving a valid Registry or a runtime policy input. + +DPV remains a semantic projection, not Relay's policy language. Relay executes its small typed access contract, never arbitrary RDF, DPV rules, ODRL, or remote vocabulary content. DPV 2.3 is a W3C Community Group report rather than a W3C Recommendation, so the profile and vocabulary digest must be pinned and upgraded deliberately. + +### Registry operations and safe requester minimization + +A resource compiles only the operations its publisher declares: collection listing, identifier read, and named exact lookup. A resource may expose any appropriate subset. An exact-lookup-only resource compiles no enumeration or identifier-read operation. + +Collection queries use publisher-defined, typed, camelCase filter parameters +directly in the query string, for example `status=ACTIVE`. Version one supports +exact equality. Any non-empty subset of declared filters is valid; the contract +separately declares whether an unfiltered request is allowed. `pageSize`, +`cursor`, and `fields` are reserved names. Filters in query strings are +limited to non-personal selectors. Relay binds their values as SQL parameters. +Callers cannot introduce source columns, joins, operators, expressions, +arbitrary sorting, or SQL. + +Named exact lookups define their complete required inputs, row boundary, result shape, and maximum of one result. Sensitive selectors belong in a bounded request body rather than a URL. Lookup outcomes are deliberately non-enumerating and are subject to tighter limits and audit. + +The disclosure profile's `properties` list is both the maximum and the default +property set in Version one. A caller may request a non-empty subset of those +published properties, or receive the complete list when no subset is requested. +This is a one-way minimization control: + +- it can remove top-level data properties but never add a property, select a source column, change a derivation, or bypass a row boundary; +- canonical envelope properties needed to identify and interpret the response remain present; +- property ordering and serialization remain contract-defined rather than caller-defined; +- unknown, duplicate, or otherwise invalid selections fail safely; +- Relay may read the complete fixed reviewed projection so it can validate the + authoritative Record before disclosure. Unrequested and hidden columns are + never serialized. Physical column-read minimization is an optimization, not + a Version one correctness contract. + +This is not dynamic attribute authorization. Version one has one reviewed +maximum disclosure profile per operation. Different operations may use +different profiles, but one operation does not select a different maximum from +the caller's identity or scopes. Supporting different entitlements for two +consumers of the same operation is a documented future gap. Within the +authorized profile, requester-selected fields can only disclose less, so a +valid subset requires no additional field-level authorization decision. It +never lowers the operation's compiled handling level, authentication, audit, +quota, metadata, or cache posture. + +### HTTP contract + +The initial public binding is deliberately small: + +```text +GET /health +GET /ready +GET /openapi.json +GET /v2 +GET /v2/resources?pageSize=...&cursor=... +GET /v2/resources/{resource} +GET /v2/resources/{resource}/records?pageSize=...&cursor=...&status=...&fields=... +GET /v2/resources/{resource}/records/{recordIdentifier}?fields=... +POST /v2/resources/{resource}/lookups/{lookup}?fields=... +GET /v2/artifacts/{artifactIdentifier} +``` + +`GET /v2` is the Registry service-metadata document. It publishes the Registry +identifier, name, Authority, operator, authoritative scope, product and API +binding versions, pinned alignment targets, derived visible Consultation +capabilities, and links to resources and artifacts. Registry identity is public; +resource, operation, schema, semantic, classification, and processing details +remain subject to their compiled visibility. Treating this service document as +an unpaginated collection is a recorded API-guide linter limitation, not a +reason to hide the first-class Registry. + +Only compiled operations and visibility-appropriate metadata routes exist. +Record path identifiers are opaque and URL-safe. Personal or compound +selectors belong only in bounded named-lookup bodies. The lookup POST is a +naturally idempotent bounded consultation and returns at most one Record. + +Lists use `pageSize`, `cursor`, and the envelope +`{items, pageInfo: {nextCursor}, meta}`. `nextCursor` is nullable. Ordering is +contract-defined with the Record identifier as a unique tie-breaker. The opaque +authenticated cursor binds the contract and source revisions, operation, +filters, order, selected fields, authorization context, and expiry. Every page +is reauthorized. Callers cannot choose an order. + +Single-record reads and resolved lookups use `{data, meta}`. `data` contains +the Registry Core context and `domainData`. `fields` is a documented Relay +extension: a non-empty, duplicate-free comma-separated list of public property +keys. A property key is the contract's URL-safe camelCase name, not a source +column or semantic IRI. Exactly one `fields` parameter is accepted; empty +members, whitespace, repeats, and duplicate keys are invalid. It only narrows +`domainData`; Registry Core context cannot be removed, and response ordering +remains contract-defined rather than request-defined. + +Ordinary JSON is the default. `application/ld+json` adds the generated context +and a derived global `@id` while preserving all Registry Core identifiers and +the same selected domain values. Responses vary on `Accept`; unsupported +representations receive `406`. Where caching is allowed, the strong ETag hashes +the exact representation bytes, including the +field subset, and supports `If-None-Match` with `304`. Every cacheable public +response includes `Vary: Accept, Authorization` so an anonymous `200` cannot +satisfy a request carrying an invalid bearer. Non-public and unversioned-live responses are +`no-store` and emit no ETag. + +The packaged deployment contains the full generated OpenAPI 3.1 contract. The +unauthenticated `/openapi.json` endpoint serves a deterministic safe public +projection and omits protected resources, selector shapes, and operator-only +metadata. Protected resource metadata and referenced artifacts use the same +compiled operation gate as the Record that links them. Relay never generates +caller-specific OpenAPI at request time. + +Every `schemaReference` is a permitted-representation schema: Registry Core is +required, while a selectable `domainData` property is validated when present. +A separate operator validation schema and SHACL shape describe the complete +source Record and preserve source requiredness. `semanticModelReference` points +to the generated local vocabulary/model, not merely the JSON-LD context. The +context is linked separately in `meta`. Compilation fails unless every audience +that can receive a Record can also retrieve safe projections of the exact +schema and semantic model referenced by that Record. + +Errors remain Registry Stack RFC 9457 problems with stable Registry Stack +`code` values and `https://id.registrystack.org/problems/...` type URIs. Relay +does not adopt draft GovStack BB codes or problem namespaces. V2 accepts W3C +Trace Context; every Problem `traceId` is the effective valid or server-created +trace ID as 32 lowercase hexadecimal characters. Caller-supplied `tracestate` +is never propagated because Relay cannot establish that vendor state is +value-free. Unknown, +hidden, ambiguous, and unsafe lookup outcomes use the same `404` status, +problem code, fixed detail, schema, cache and security headers, differing only +in independently generated trace correlation. Problems never echo selectors, +identifiers, source values, SQL, paths, tokens, or policy internals. + +### Explicit enumeration posture + +Every resource declares one orthogonal enumeration posture: + +- `public` requires a public list operation; +- `protected` requires a scope-protected list operation; +- `none` forbids a list operation. + +Read and named exact-lookup operations are declared independently with their +own access rules and disclosure profiles. This lets a resource expose protected +read plus exact lookup without pretending it is an enumerable collection. +Exact lookup is the preferred operation for registries containing people or +sensitive entities. Sensitive selectors belong in a bounded request body +rather than a URL. Caller-controlled SQL, joins, expressions, projection +expressions, and arbitrary sorting are never accepted. + +Reviewed SQLite views are the source disclosure boundary. They exclude internal +columns, normalize public values, delink identifiers, implement reviewed +derivations, and expose only intended source bindings. Relay never accepts +caller-created projections. A request may only narrow the authorized compiled +disclosure profile as described above. + +### Small, explainable access decisions + +The initial access model combines: + +- a strictly verified OAuth 2.0 JWT access token when the operation is protected; +- one explicit operation scope; +- optional trusted purpose; +- optional authority-to-row binding from the resolved principal or a verified claim; +- the selected disclosure profile. + +Purpose comes from, or is constrained by, verified authority. A caller header never creates authority. Principal binding is a compiler-declared equality boundary injected by Relay and cannot be replaced by caller filters. + +The resource posture and contract define the maximum compiled operation set. Token scopes can only narrow it. Separate scopes for list, read, and named lookup allow an issuer to give a client exact-lookup access without collection or identifier-read access. Conversely, no token can enable an operation the resource did not compile. Relay does not maintain a client registry; the trusted issuer registers clients and assigns scopes. + +Each request produces a typed access decision followed by a typed disclosure plan. The plan contains the authorized operation, row constraints, maximum disclosure profile, and any requester-selected property subset. This architectural seam keeps authentication, authorization, row constraints, query construction, and serialization separate. Version one uses static reviewed disclosure plans and does not require a general PDP, CEL, dynamic masking, per-client field permissions, or tag-based ABAC. + +### Token issuers and optional Registry Mint + +Relay is an OAuth 2.0 resource server. Protected operations accept a narrow +registered JWT access-token profile with strict issuer, audience, subject or +client, time, token identifier, algorithm, key, type, and scope validation. +Version one configures at most one issuer per Registry deployment through the +existing governed OIDC discovery path. Additional discovery modes and +multi-issuer selection wait for demonstrated deployments. + +The token may come from an institution's existing identity provider or authorization server. Registry Mint is an optional issuer for machine-to-machine deployments that do not have one. Relay has no production runtime dependency on Mint and does not need to know which conforming issuer produced a token. + +Mint can be improved separately to issue product-neutral, registered scopes and audiences for Relay, with optional authority-controlled purpose and row-binding claims. Mint must write that authority server-side rather than copy authority from a caller. This is an interoperability profile between two independent products, not a special Mint authentication mode in Relay. + +Public operations may explicitly allow anonymous access. They still use the same compiled disclosure, validation, bounds, provenance, and audit model appropriate to public publication. + +### Unsigned responses and Evidence composition + +Relay V2 registry responses are not signed. TLS protects transport, access tokens protect controlled operations, and provenance, revisions, and tamper-evident audit support accountability. An ETag, source digest, or contract revision is useful integrity and cache metadata, but is not presented as a signature. + +When a relying party needs a portable signed assertion with minimum disclosure, that remains Evidence's job. Evidence may use a Relay-protected exact lookup as an ordinary fixed HTTP source. The products compose without moving assertion signing or verification into Relay. + +### Trust, visibility, and failure boundaries + +A deployment names the Registry Authority, privacy controller, registry +publisher, Relay operator, token issuer, recipient classes, and audit owner. +One Relay process serves exactly one Registry in one administrative trust +domain. A Registry may contain several related resources under the same +authority and authoritative scope. Multi-registry hosting and multi-tenant +policy isolation are outside the initial core. + +Registry service identity is public. Other discovery and governance metadata is +`public`, `operation-bound`, or `operator-only`. Operation-bound artifacts use +the same static access gate as the operation whose Record links them; separate +operation profiles receive separate safe artifacts where necessary. +Operator-only artifacts stay in the sealed package and CLI and are never +mounted. Semantic transparency must not accidentally publish sensitive schema, +selector, classification, or processing details. + +Exact lookup returns a stable unresolved outcome for no match, ambiguous match, a record hidden by policy, or a source record that cannot safely be disclosed. Syntactically invalid requests receive a bounded public error without echoing values. Relay never skips, coerces, or partially releases an invalid selected source record to keep a response successful. + +A complete contract revision is compiled, validated, and activated atomically at startup. Relay never mixes revisions, falls back to a previous interpretation silently, or hot-reloads a partially valid contract. + +### SQLite source profiles + +Relay supports two explicit SQLite profiles: + +- snapshot: read-only immutable access, stable file identity and digest, no uncheckpointed sidecars, and exact source revision; +- live read-only: another trusted process may update the database, while Relay keeps a fixed registry contract, uses a consistent read transaction per request, and verifies schema fingerprints when the SQLite schema changes. + +Snapshot mode provides stronger reproducibility and provenance, but is optional. Live mode never claims the exact historical reproducibility of a captured snapshot. In both modes the Relay process has read-only operating-system and SQLite access, even though a trusted publisher may hold separate write access to a live database. + +Snapshot responses identify the captured source digest and may support lists, +cursors, and validators. Version one live sources are deliberately unversioned: +they support read and exact lookup only, are always `no-store`, emit no ETag, +make no reproducibility claim, and carry `sourceRevision` with `profile: live`, +`status: unversioned`, and `value: null`. Publisher-owned revisions, live +pagination, and live caching are deferred until a real registry requires them. + +### Audit, provenance, and change evidence + +Every data request, including anonymous public access, durably records either a +refusal before returning or a pre-source attempt followed by a terminal +release or unresolved outcome. Audit is a source-access and response-release +gate. An event identifies the Registry, resource, operation, access-rule +revision, purpose when present, applied row-boundary kind, disclosure profile, +selected property identifiers or their digest, effective handling levels, +contract revision, and snapshot or live-source revision. Anonymous public +events use an explicit anonymous principal kind and no synthetic person +identifier. Audit contains no tokens, selector values, source values, response +values, or raw subject identifiers. + +Audit covers requests processed by Relay. A permitted public shared-cache hit +does not reach Relay and therefore cannot create a Relay audit event; the +safeguards matrix names that observability boundary rather than implying +end-to-end access transparency. + +The declared processing description, compiled access decision, and audit event share stable identifiers. This connects intended governance to enforcement and observed use without turning every audit event into RDF. + +Authoring tooling compares contract revisions and highlights newly exposed +properties, relaxed classifications, wider enumeration or operation access, +removed row bindings, expanded purposes or scopes, changed source views, and +semantic mapping changes. Git and CI remain the review workflow; Relay does not +implement an approval portal. + +### Safeguards evidence + +Relay maintains a small evidence matrix: + +```text +safeguard principle +-> concrete mechanism +-> enforcement point +-> negative test +-> generated or audit evidence +-> institutional responsibility outside Relay +``` + +This supports implementation evidence for the [Universal DPI Safeguards Framework](https://www.dpi-safeguards.org/framework), especially privacy by design, security by design, protection during use, transparency, evidence-led evolution, and open assets. Exact-lookup confinement, property-level classification, safe requester minimization, metadata visibility, value-free audit, and change-impact review provide concrete mechanisms rather than compliance labels. This is not certification and does not create lawful basis, inclusion practice, independent oversight, or remedy. + +## Product boundary + +Relay V2 is: + +- a governed semantic registry publisher; +- a protected read-only API over existing authoritative data; +- a compiler and runtime for explicit registry contracts; +- a source of portable semantic, governance, alignment, verification, and audit evidence. + +Relay V2 is not: + +- a generic SQLite REST generator; +- a write API or registry administration system; +- a query language, SQL proxy, data lake, warehouse, or ETL platform; +- an RDF store, SPARQL endpoint, or inference engine; +- a general policy engine, PDP, consent service, or entitlement workflow; +- an automatic legal classifier or data-loss-prevention scanner; +- a credential or signed-assertion issuer; +- a grievance, eligibility, case-management, or identity-matching system; +- a multi-source analytics or interoperability-protocol suite; +- an extension, mode, or command group of the existing `registryctl`. + +PostgreSQL and other source adapters, GeoJSON and SpatiaLite, richer semantic profiles, and additional registry protocols are later profiles. The initial architecture should leave room for source adapters, but version one should not introduce a generic storage trait before a second adapter proves the abstraction. + +## Reuse from `registry-platform-*` + +Relay V2 should reuse mature product-neutral primitives directly and avoid inheriting the current Relay product surface through them. + +### Direct reuse + +| Platform crate | Relay V2 use | +|---|---| +| `registry-platform-authcommon` | Strict bearer parsing and secret-safe authentication helpers. | +| `registry-platform-oidc` | Existing OIDC discovery, JWKS caching, and strict JWT access-token verification for the one configured issuer. Add only missing product-neutral claim checks required by Relay V1; additional discovery modes are deferred. | +| `registry-platform-httpsec` | Security headers, narrow CORS, request limits, and RFC 9457 problem responses extended by Relay with its stable code and trace ID. | +| `registry-platform-audit` | Tamper-evident envelopes, durable sinks, chain verification, redaction, and pseudonymization primitives. Relay V2 owns its event vocabulary and does not inherit old consultation semantics. | +| `registry-platform-config` | Environment expansion, secret references, and optional signed governed-bundle verification. Relay still owns compilation of the registry contract. | +| `registry-platform-canonical-json` | Deterministic revision, profile, and artifact digests. | +| `registry-platform-buildinfo` | Consistent binary and release identity. | +| `registry-platform-testing` | Mock OIDC, audit assertions, HTTP/security integration fixtures, and non-leak testing. | + +### Selective or indirect reuse + +- `registry-platform-crypto` is useful for signed configuration, digests, and pseudonymization support, but Relay V2 does not sign registry responses. +- `registry-platform-httputil` is used indirectly by OIDC. Relay's SQLite-only version has no general outbound data-source boundary. +- `registry-platform-ops` may provide generic readiness, posture, and audit-shipping contracts after checking that they do not pull in the existing Relay control-plane lifecycle. +- Registry Mint is an optional conforming token issuer, not a platform dependency. Any wider audience, scope, purpose, or binding support belongs in Mint's own product-neutral token profile. + +### Do not reuse in the initial core + +- `registry-platform-pdp`: its broad context, ODRL, redaction, assurance, consent, jurisdiction, and credential-format model is outside the small Relay V2 access decision. +- `registry-platform-sdjwt`: signed assertions remain Evidence's responsibility. +- existing Relay API-key, state-plane, hot-reload, destination, materialization, policy, aggregate, and protocol-specific machinery. +- the existing `registryctl` crate, commands, project model, or compatibility surface. + +### Registry Manifest boundary + +The Relay authoring contract is a strict Relay-owned `RegistryContract`. A +future portability command may compile it one way into a +`registry-manifest/v1` projection. Relay V1 does not need that projection to +compile, package, or serve. It is not a strict Registry Manifest profile because +Manifest intentionally does not own source columns, access rules, filters, +disclosure, classification, purpose, row binding, or runtime limits. + +Relay access and execution fields must not be added to Registry Manifest. The +projection carries only portable registry, dataset, entity, property, +identifier, codelist, semantic, and service metadata that Manifest already +owns. The current Manifest entity and dataset model already provides titles and +descriptions; any future Manifest change must be narrowly portable and useful +outside Relay. + +## What to extract from Evidence + +The immediate product-neutral extraction is a hardened SQLite foundation named +`registry-platform-sqlite`. + +Evidence already proves important snapshot behavior in its bundle and SQLite source code: stable file capture, digest and identity checks, sidecar refusal, immutable read-only opening, SQLite authorization, engine limits, progress cancellation, bounded concurrency, typed values, and value-free failures. + +The extracted crate should own: + +- safe snapshot and live read-only open profiles; +- snapshot identity, digest, and sidecar validation; +- live schema fingerprint support; +- defensive SQLite configuration and authorizer primitives; +- step, time, value, row, response, and concurrency bounds; +- safe error classification and typed row-reading helpers. + +Evidence keeps its product semantics: + +- one reviewed statement per source; +- the `evidence_extract` publication metadata contract and maximum age; +- selector-to-parameter binding; +- match, no-match, and ambiguous outcomes; +- fixed fact schemas, requirement derivation, and assertion construction. + +Relay keeps its product semantics: + +- resource and property bindings; +- enumeration posture, operation access, and disclosure plans; +- identifier, predefined filter, named lookup, safe property selection, and pagination behavior; +- semantics, classifications, DPV processing descriptions, and registry serialization. + +Other Evidence work should be reused as method before it is reused as code: + +- atomic startup capture and compilation; +- closed artifact sets and deterministic revisions; +- value-free adopter diagnostics; +- fixed public problem classes; +- coequal acceptance definitions; +- security invariant and test traceability matrices; +- generated-contract drift checks; +- source-product neutrality checks; +- fail-closed release and audit gates. + +No general contract compiler, policy evaluator, semantic model, or runtime pipeline should be extracted until two products have independently proven the same abstraction. + +## Initial delivery shape + +The first coherent Relay V2 release should contain: + +1. the `relay` runtime with a concise authoring contract and offline compiler; +2. a separate `relayctl` for scaffolding, inspection, generation, testing, diffing, and packaging; +3. one first-class Registry per deployment and mandatory Registry Core context on every Record; +4. generated Consultation capability discovery and a concise Digital Registries alignment note; +5. generated local semantics, JSON-LD, JSON Schema, SHACL, full packaged OpenAPI, and safe public OpenAPI; +6. property classification with provenance and the `public`, `internal`, `confidential`, and `restricted` handling levels; +7. explicit public, protected, or absent enumeration with independently compiled list, read, and named-lookup operations; +8. `pageSize` and opaque-cursor lists, direct predefined equality filters, and safe caller selection of fewer properties than the operation profile; +9. strict OAuth JWT access-token verification, operation scopes, trusted purpose, optional authority row binding, and an optional conforming Mint issuer; +10. snapshot and live read-only SQLite profiles, including useful unversioned live read and lookup deployments; +11. deterministic disclosure plans, bounded queries, stable `404` lookup outcomes, atomic activation, and Registry Stack problems; +12. unsigned registry responses with truthful Record, source, and contract revisions; +13. tamper-evident attempt, refusal, and pre-release audit gates; +14. fixture, schema-drift, contract-drift, change-impact, and security-safeguard checks. + +This is enough to establish Relay as a governed semantic registry publisher rather than a database API. Additional storage engines, spatial data, richer policy, and protocol profiles can then be judged against that identity. + +## Definition of Done and coequal acceptance registries + +The detailed [Relay V2 Definition of Done](DEFINITION-OF-DONE.md) is the completion contract. Every required row must pass on one revision; one working SQLite endpoint or registry is not completion. + +Three coequal acceptance deployments prevent the runtime from inheriting one +registry domain. They are three separate Registries exercised as independently +instantiated one-Registry services over real loopback HTTP, plus one packaged +real-process start, request, stop, and restart smoke: + +- a sensitive social assistance registry proves exact-lookup-only consultation, trusted purpose, row binding, protected classification, and live SQLite; +- a public business registry proves deterministic list and identifier read, predefined filters, public semantic alignment, snapshot reproducibility, and caching; +- a protected civil-event registry proves separate read and lookup scopes, + operation-specific disclosures, optional conforming Mint tokens, + unversioned-live truthfulness, and the ordinary protected-source boundary a + future Evidence integration can use. + +All three must use the same compiler, operation model, SQLite executor, +disclosure planner, serializers, access-decision types, audit vocabulary, +problem model, and adopter workflow. Focused parameterized compiler and runtime +tests prove that state cannot cross resource boundaries without creating a +fourth deployment project. +Production Rust and public generic contracts contain no social, business, or +civil-registration domain branches. + +The [Relay V2 Configuration Examples](CONFIGURATION-EXAMPLES.md) +exercise these three definitions and define the intended Version 1 authoring +shape. The generated schema makes their constraints precise. + +## Decisions fixed for Version 1 + +- Relay owns a strict registry contract. A Registry Manifest projection is a later portability artifact, not the Relay execution contract. +- One contract and process serves one Registry, which may contain several resources. +- Registry Core context is mandatory and requester field selection can narrow only `domainData`. +- Lists use `pageSize`, `cursor`, `items`, and `pageInfo.nextCursor`; predefined filters are direct camelCase equality parameters. +- Named exact lookup remains a bounded POST action and maps to constrained Consultation Search, not Record Match. +- One reviewed maximum disclosure profile exists per operation. Caller-dependent entitlement variants are deferred. +- Handling levels are `public`, `internal`, `confidential`, and `restricted`; purpose and row binding are separate explicit constraints. +- Snapshot SQLite is valuable but optional. Unversioned live sources are `no-store` and do not compile paginated lists. +- A deployment configures at most one issuer in Version one. Responses are unsigned. Registry Mint is optional, never a Relay runtime dependency, and may be paired when it emits the same standard token profile. +- Registry Stack problem codes and type URIs remain canonical. GovStack compatibility is a later profile, not a core wire mode. +- The written GovStack drafts inform alignment. Their legacy OpenAPI does not. + +## Deliberate future gaps + +- different maximum disclosure entitlements for two consumers of the same operation; +- publisher-owned live revisions, live pagination, and live caching; +- multi-issuer selection and a frozen `relay`/`relayctl` subprocess protocol; +- Registry Manifest, DPV, safeguards, and machine-readable GovStack alignment projections; +- formal GovStack conformance or a compatibility flag translating wire conventions; +- a Digital Registries family beyond Consultation; +- additional sources, GeoJSON, and SpatiaLite; +- dynamic masking, a general PDP, write operations, notification, access-history publication, and response signing. diff --git a/products/relay-v2/CONFIGURATION-EXAMPLES.md b/products/relay-v2/CONFIGURATION-EXAMPLES.md new file mode 100644 index 000000000..915693e23 --- /dev/null +++ b/products/relay-v2/CONFIGURATION-EXAMPLES.md @@ -0,0 +1,902 @@ +# Relay V2 Configuration Examples + +Status: Illustrative design probes +Date: 2026-08-09 +Product direction: [Relay V2 Product Concept](CONCEPT.md) +Acceptance boundary: [Relay V2 Definition of Done](DEFINITION-OF-DONE.md) + +## How to read these examples + +These examples test whether one small authoring model can describe materially +different registries. Their named keys and boundaries are the intended Version +1 authoring shape; the generated schema will make their constraints precise. +Relay owns the strict contract. A Registry Manifest projection is later +portability tooling, not a Version one runtime input. + +The intended boundaries are firmer than the syntax: + +- `RegistryContract` is governed, versioned, compiled at startup, and cannot be overridden by runtime configuration; +- `RelayRuntime` binds deployment-local paths, listeners, token issuers, and audit storage without changing resources, operations, disclosure, or semantics; +- SQLite views and columns are source bindings, while resources and properties are the public model; +- one contract describes one Registry; each resource is a Record type within it; +- every Record has mandatory Registry Core bindings in addition to selectable domain properties; +- `sourceRequired` governs complete source-Record validation, while the public representation schema permits any compiled selectable `domainData` subset; +- external semantic alignment is optional and file-based, pinned, and reviewed; +- every operation chooses a maximum disclosure profile, and the requester may only select fewer properties; +- token issuers assign authority, but they cannot enable an operation Relay did not compile; +- family capabilities are derived from compiled operations, never duplicated in configuration; +- none of these examples enables response signing. + +Each short classification entry inherits scheme versions and review provenance +from its contract-level `classifications` block. Each external mapping file is +digest-pinned and must name an explicit exact, close, broad, narrow, or related +relation for every mapped term. + +The examples pin written standards as alignment targets. They do not consume +the legacy Digital Registries OpenAPI or claim GovStack conformance. The API +binding uses `pageSize`, `cursor`, direct camelCase equality filters, the +`items`/`pageInfo.nextCursor` list envelope, and Registry Stack problems. + +Every successful item has the same non-selectable core shape. For example: + +```json +{ + "registryIdentifier": "urn:example:registry:registered-businesses", + "recordIdentifier": "B-00142", + "revisionIdentifier": "17", + "lifecycleState": "ACTIVE", + "schemaReference": "https://business.example.invalid/v2/artifacts/registered-business.schema.json", + "semanticModelReference": "https://business.example.invalid/v2/artifacts/registered-business.vocabulary.jsonld", + "authorityIdentifier": "urn:example:institution:company-registrar", + "recordedAt": "2026-08-01T10:30:00Z", + "domainData": { + "legalName": "Example Cooperative", + "registrationStatus": "ACTIVE" + } +} +``` + +`fields=legalName,registrationStatus` narrowed only `domainData`. List responses +place such items in `items`; single reads and resolved lookups place one in +`data`. The semantic-model reference resolves to the generated local vocabulary; +the representation `meta` links the JSON-LD context separately. + +Each example shows the governed and runtime documents together for readability. A real project would keep them as separately validated files and package them with synthetic fixtures and generated artifacts. + +## Example 1: social assistance enrolment + +This is a sensitive, exact-lookup-only registry. It deliberately has no list or identifier-read operation. An authorized service officer supplies two exact selectors in a bounded request body. Relay also binds the verified officer's service area to a hidden source column. The response discloses a reviewed status summary, not names, addresses, dates of birth, selectors, or household membership. + +The Version one live source is intentionally unversioned. Record revisions +still come from each row, while response and audit source revision are +explicitly unavailable and every response is `no-store`. + +```yaml +apiVersion: relay.registrystack.org/v2alpha1 +kind: RegistryContract +metadata: + id: social-assistance-enrolments + version: 2026-08-01 + title: Social assistance enrolment consultations + +registry: + registryIdentifier: urn:example:registry:social-assistance-enrolments + name: Social assistance enrolment registry + authority: + identifier: urn:example:institution:social-protection-authority + name: Social Protection Authority + operator: + identifier: urn:example:institution:digital-service-operator + name: Government Digital Service Operator + authoritativeScope: Social assistance enrolment decisions in the declared jurisdiction + baseUri: https://social-registry.example.invalid/registry/ + identifierLifecyclePolicyRef: governance/record-identifiers.yaml + alignmentTargets: + - {name: govstack-digital-registries, version: 3.0.0-alpha.2, cfrTarget: govstack-cfr-2.1.0, status: directional} + - {name: govstack-api-design-guide, version: 0.1.0-draft, status: directional} + +governance: + controller: urn:example:institution:social-protection-authority + publisher: urn:example:institution:social-registry-office + auditOwner: urn:example:institution:internal-audit + +semantics: + localVocabulary: https://social-registry.example.invalid/vocabulary/ + +classifications: + privacy: {scheme: "https://w3id.org/dpv", version: "2.3"} + institutional: {scheme: "urn:example:classification:social-protection", version: "2026-08-01"} + handling: {scheme: "https://id.registrystack.org/vocab/handling", version: "1"} + provenanceRef: governance/classification-review.yaml + +sources: + assistance: + kind: sqlite + profile: live-read-only + expectedSchemaFingerprint: sha256:1111111111111111111111111111111111111111111111111111111111111111 + +resources: + - id: assistance-enrolment + title: Assistance enrolment + description: Reviewed consultation view of one assistance enrolment Record + semanticClass: local:AssistanceEnrolment + source: + source: assistance + view: relay_assistance_enrolments + classificationDefaults: {institutional: restricted, handling: restricted, status: reviewed} + recordContext: + recordIdentifier: {sourceColumn: enrolment_reference} + revisionIdentifier: {sourceColumn: record_revision} + lifecycleState: {sourceColumn: lifecycle_state, codelist: codelists/record-lifecycle.yaml} + recordedAt: {sourceColumn: recorded_at} + sourceColumnClassifications: + record_revision: {privacy: derived} + lifecycle_state: {privacy: personal-context} + recorded_at: {privacy: personal-context} + case_reference: {privacy: identifying} + person_reference: {privacy: identifying} + service_area_code: {privacy: personal-context} + + properties: + enrolmentReference: + label: Enrolment reference + description: Stable reference assigned to the enrolment Record + sourceColumn: enrolment_reference + type: string + sourceRequired: true + semanticTerm: local:enrolmentReference + classification: {privacy: identifying} + programmeCode: + label: Programme code + description: Reviewed code for the assistance programme + sourceColumn: programme_code + type: controlled-code + codelist: codelists/programmes.yaml + sourceRequired: true + semanticTerm: local:programme + classification: {privacy: sensitive-personal} + enrolmentStatus: + label: Enrolment status + description: Current reviewed status of the enrolment + sourceColumn: enrolment_status + type: controlled-code + codelist: codelists/enrolment-status.yaml + sourceRequired: true + semanticTerm: local:enrolmentStatus + classification: {privacy: sensitive-personal} + entitlementCategory: + label: Entitlement category + description: Optional reviewed category of entitlement + sourceColumn: entitlement_category + type: controlled-code + codelist: codelists/entitlement-categories.yaml + sourceRequired: false + semanticTerm: local:entitlementCategory + classification: {privacy: sensitive-personal} + validThrough: + label: Valid through + description: Optional final date of the current enrolment validity + sourceColumn: valid_through + type: date + sourceRequired: false + semanticTerm: local:validThrough + classification: {privacy: personal} + serviceOfficeCode: + label: Service office code + description: Reviewed service office responsible for the enrolment + sourceColumn: service_office_code + type: controlled-code + codelist: codelists/service-offices.yaml + sourceRequired: true + semanticTerm: local:serviceOffice + classification: {privacy: personal-context, institutional: internal} + + disclosureProfiles: + consultation: + properties: [enrolmentReference, programmeCode, enrolmentStatus, entitlementCategory, validThrough, serviceOfficeCode] + + operations: + lookups: + - id: by-case-and-person + access: + scope: registry:social-assistance:lookup + purpose: + claim: purpose + allowed: [benefit-delivery] + authorityRowBinding: + claim: service_area + sourceColumn: service_area_code + requestBody: + maximumBytes: 512 + selectors: + caseReference: {sourceColumn: case_reference, type: string, minimumBytes: 8, maximumBytes: 96} + personReference: {sourceColumn: person_reference, type: string, minimumBytes: 8, maximumBytes: 96} + disclosureProfile: consultation + + processingDescriptions: + - id: benefit-delivery-consultation + operationRefs: ["lookup:by-case-and-person"] + purpose: benefit-delivery + recipientClass: authorized-service-officer + legalBasisRef: governance/social-assistance-legal-basis.yaml + dpvProfileRef: governance/social-assistance-processing.dpv.yaml + safeguards: [exact-lookup, principal-row-binding, property-minimization, value-free-audit] + +metadataVisibility: + service: public + resources: operation-bound + semantics: operation-bound + classifications: operator-only + processing: operation-bound + +--- +apiVersion: relay.registrystack.org/v2alpha1 +kind: RelayRuntime +server: {bind: "127.0.0.1:8080"} +packagePath: /srv/relay/social-assistance-package +sources: + assistance: {path: /srv/registries/social-assistance.sqlite} +authentication: + issuer: + id: institutional-authorization-server + discoveryUrl: https://identity.example.invalid/.well-known/openid-configuration + audience: relay-social-assistance + tokenTypes: [at+jwt] + algorithms: [ES256] +audit: + sink: /var/lib/relay/audit/social-assistance.jsonl + integrityKeyRef: secret:file/audit-integrity-key +limits: {requestTimeoutMilliseconds: 1500, concurrentQueries: 16} +quotas: {requestsPerMinute: 120, burst: 20} +``` + +What this example must prove: + +- even a broadly scoped token cannot create list or identifier-read routes; +- lookup scope, trusted purpose, and service-area binding are all required; +- selectors and the hidden `service_area_code` never become public properties; +- selecting `enrolmentStatus,validThrough` returns less than the authorized default without changing authorization; +- a useful local vocabulary, JSON-LD context, JSON Schema, and SHACL starter are generated without any external vocabulary mapping; +- no match, ambiguity, a hidden row, and an invalid source record return the same `404` problem except for trace correlation; +- mandatory Registry Core context remains present and capability discovery derives only constrained `consultation.search`. + +## Example 2: public business registry + +This is a genuinely public snapshot. It supports deterministic collection listing and identifier read without a token. Filters are predefined exact matches. The source view deliberately omits directors, beneficial owners, full addresses, filing history, and internal source keys. + +```yaml +apiVersion: relay.registrystack.org/v2alpha1 +kind: RegistryContract +metadata: + id: registered-businesses + version: 2026-08-01 + title: Registered businesses + +registry: + registryIdentifier: urn:example:registry:registered-businesses + name: Registered business registry + authority: + identifier: urn:example:institution:company-registrar + name: Company Registrar + operator: + identifier: urn:example:institution:digital-service-operator + name: Government Digital Service Operator + authoritativeScope: Legal business registrations in the declared jurisdiction + baseUri: https://business.example.invalid/registry/ + identifierLifecyclePolicyRef: governance/record-identifiers.yaml + alignmentTargets: + - {name: govstack-digital-registries, version: 3.0.0-alpha.2, cfrTarget: govstack-cfr-2.1.0, status: directional} + - {name: govstack-api-design-guide, version: 0.1.0-draft, status: directional} + +governance: + controller: urn:example:institution:company-registrar + publisher: urn:example:institution:company-registrar + auditOwner: urn:example:institution:company-registrar-audit + +semantics: + localVocabulary: https://business.example.invalid/vocabulary/ + alignments: + - id: semic-core-business + version: 2.0.0 + profileRef: semantics/semic-core-business.yaml + digest: sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb + relationRequired: true + +classifications: + privacy: {scheme: "https://w3id.org/dpv", version: "2.3"} + institutional: {scheme: "urn:example:classification:company-registrar", version: "2026-08-01"} + handling: {scheme: "https://id.registrystack.org/vocab/handling", version: "1"} + provenanceRef: governance/classification-review.yaml + +sources: + companies: + kind: sqlite + profile: snapshot + expectedSchemaFingerprint: sha256:2222222222222222222222222222222222222222222222222222222222222222 + +resources: + - id: registered-business + title: Registered business + description: Public registration facts for one legal business Record + semanticClass: local:RegisteredBusiness + source: + source: companies + view: relay_registered_businesses + classificationDefaults: {privacy: non-personal, institutional: public, handling: public, status: reviewed} + recordContext: + recordIdentifier: {sourceColumn: registration_number} + revisionIdentifier: {sourceColumn: record_revision} + lifecycleState: {sourceColumn: lifecycle_state, codelist: codelists/record-lifecycle.yaml} + recordedAt: {sourceColumn: recorded_at} + sourceColumnClassifications: + record_revision: {} + lifecycle_state: {} + recorded_at: {} + + properties: + registrationNumber: + label: Registration number + description: Stable public identifier assigned by the Company Registrar + sourceColumn: registration_number + type: string + sourceRequired: true + semanticTerm: local:registrationNumber + legalName: + label: Legal name + description: Current registered legal name of the business + sourceColumn: legal_name + type: string + sourceRequired: true + semanticTerm: local:legalName + classification: {privacy: potentially-personal, institutional: public-by-law} + registrationStatus: + label: Registration status + description: Current lifecycle status of the business registration + sourceColumn: registration_status + type: controlled-code + codelist: codelists/business-status.yaml + sourceRequired: true + semanticTerm: local:registrationStatus + legalForm: + label: Legal form + description: Registered legal form of the business + sourceColumn: legal_form + type: controlled-code + codelist: codelists/legal-forms.yaml + sourceRequired: true + semanticTerm: local:legalForm + registeredJurisdiction: + label: Registered jurisdiction + description: Jurisdiction in which the business is registered + sourceColumn: jurisdiction_code + type: controlled-code + codelist: codelists/jurisdictions.yaml + sourceRequired: true + semanticTerm: local:registeredJurisdiction + registeredOfficeArea: + label: Registered office area + description: Public administrative area of the registered office + sourceColumn: registered_office_area + type: controlled-code + codelist: codelists/office-areas.yaml + sourceRequired: false + semanticTerm: local:registeredOfficeArea + classification: {privacy: potentially-personal, institutional: public-by-law} + + disclosureProfiles: + public-register: + properties: [registrationNumber, legalName, registrationStatus, legalForm, registeredJurisdiction, registeredOfficeArea] + + operations: + list: + access: public + disclosureProfile: public-register + filters: + - {name: status, property: registrationStatus, type: controlled-code} + - {name: jurisdiction, property: registeredJurisdiction, type: controlled-code} + allowUnfiltered: true + orderBy: [registrationNumber] + pagination: {defaultPageSize: 50, maximumPageSize: 200} + read: + access: public + disclosureProfile: public-register + + processingDescriptions: + - id: statutory-publication + operationRefs: [list, read] + purpose: statutory-publication + recipientClass: public + legalBasisRef: governance/business-register-publication.yaml + dpvProfileRef: governance/business-register-processing.dpv.yaml + safeguards: [reviewed-public-view, property-minimization, deterministic-pagination, change-impact-review] + +metadataVisibility: + service: public + resources: public + semantics: public + classifications: public + processing: public + +--- +apiVersion: relay.registrystack.org/v2alpha1 +kind: RelayRuntime +server: {bind: "127.0.0.1:8080"} +packagePath: /srv/relay/business-register-package +sources: + companies: {path: /srv/registries/business-register.sqlite} +authentication: {issuer: null} +audit: + sink: /var/lib/relay/audit/business-register.jsonl + integrityKeyRef: secret:file/audit-integrity-key +limits: {requestTimeoutMilliseconds: 1500, concurrentQueries: 32} +``` + +What this example must prove: + +- public means explicitly compiled public access, not absence of a global authentication setting; +- filters appear as direct camelCase parameters such as `status=ACTIVE`, and accept only the named property, datatype, codelist, and exact-equality operator; +- pagination uses `pageSize`, an opaque `cursor`, and `items` with nullable `pageInfo.nextCursor`, while publisher-declared stable ordering prevents arbitrary sorting; +- a requested subset such as `registrationNumber,legalName,registrationStatus` has its own correct ETag and JSON-LD representation; +- the captured snapshot digest and schema fingerprint make responses reproducible and strongly cacheable by revision; +- capability discovery derives `consultation.list` and `consultation.retrieve` and no other family pattern. + +## Example 3: civil-event registry + +This registry is CRVS-shaped but the runtime remains event-domain neutral. It has no collection-list operation. Authorized registrars may read a known opaque event identifier under one scope and disclosure profile. A verification client may perform only a named exact lookup under a different scope and smaller disclosure profile. It uses an ordinary external issuer for the core journey. Registry Mint may replace that issuer later when it emits the same standard token profile. + +This live source is intentionally unversioned. Its Record revision remains +source-bound, while source revision is explicitly unavailable and responses are +`no-store`. + +```yaml +apiVersion: relay.registrystack.org/v2alpha1 +kind: RegistryContract +metadata: + id: civil-events + version: 2026-08-01 + title: Civil event registrations + +registry: + registryIdentifier: urn:example:registry:civil-events + name: Civil event registry + authority: + identifier: urn:example:institution:civil-registration-authority + name: Civil Registration Authority + operator: + identifier: urn:example:institution:digital-service-operator + name: Government Digital Service Operator + authoritativeScope: Civil event registrations in the declared jurisdiction + baseUri: https://civil-registry.example.invalid/registry/ + identifierLifecyclePolicyRef: governance/record-identifiers.yaml + alignmentTargets: + - {name: govstack-digital-registries, version: 3.0.0-alpha.2, cfrTarget: govstack-cfr-2.1.0, status: directional} + - {name: govstack-api-design-guide, version: 0.1.0-draft, status: directional} + +governance: + controller: urn:example:institution:civil-registration-authority + publisher: urn:example:institution:civil-registration-authority + auditOwner: urn:example:institution:civil-registration-inspectorate + +semantics: + localVocabulary: https://civil-registry.example.invalid/vocabulary/ + alignments: + - id: publicschema-events + version: pinned-2026-08-01 + profileRef: semantics/publicschema-events.yaml + digest: sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc + relationRequired: true + +classifications: + privacy: {scheme: "https://w3id.org/dpv", version: "2.3"} + institutional: {scheme: "urn:example:classification:civil-registration", version: "2026-08-01"} + handling: {scheme: "https://id.registrystack.org/vocab/handling", version: "1"} + provenanceRef: governance/classification-review.yaml + +sources: + events: + kind: sqlite + profile: live-read-only + expectedSchemaFingerprint: sha256:3333333333333333333333333333333333333333333333333333333333333333 + +resources: + - id: civil-event + title: Civil event registration + description: Protected registration facts for one civil-event Record + semanticClass: local:CivilEventRegistration + source: + source: events + view: relay_civil_events + classificationDefaults: {institutional: restricted, handling: restricted, status: reviewed} + recordContext: + recordIdentifier: {sourceColumn: event_reference} + revisionIdentifier: {sourceColumn: record_revision} + lifecycleState: {sourceColumn: lifecycle_state, codelist: codelists/record-lifecycle.yaml} + recordedAt: {sourceColumn: recorded_at} + sourceColumnClassifications: + record_revision: {privacy: derived} + lifecycle_state: {privacy: personal-context} + recorded_at: {privacy: personal-context} + registration_number: {privacy: identifying} + jurisdiction_code: {privacy: personal-context} + + properties: + eventReference: + label: Event reference + description: Stable opaque identifier assigned to the civil-event Record + sourceColumn: event_reference + type: string + sourceRequired: true + semanticTerm: local:eventReference + classification: {privacy: identifying} + eventType: + label: Event type + description: Reviewed type of civil event + sourceColumn: event_type + type: controlled-code + codelist: codelists/civil-event-types.yaml + sourceRequired: true + semanticTerm: local:eventType + classification: {privacy: sensitive-personal} + registrationStatus: + label: Registration status + description: Current lifecycle status of the civil-event registration + sourceColumn: registration_status + type: controlled-code + codelist: codelists/civil-registration-status.yaml + sourceRequired: true + semanticTerm: local:registrationStatus + classification: {privacy: personal} + registrationDate: + label: Registration date + description: Date on which the civil event was registered + sourceColumn: registration_date + type: date + sourceRequired: true + semanticTerm: local:registrationDate + classification: {privacy: personal} + registrationArea: + label: Registration area + description: Administrative area responsible for the registration + sourceColumn: registration_area_code + type: controlled-code + codelist: codelists/registration-areas.yaml + sourceRequired: true + semanticTerm: local:registrationArea + classification: {privacy: personal-context} + certificateAvailable: + label: Certificate available + description: Whether a certificate can currently be issued + sourceColumn: certificate_available + type: boolean + sourceRequired: true + semanticTerm: local:certificateAvailable + classification: {privacy: personal} + + disclosureProfiles: + registrar-record: + properties: [eventReference, eventType, registrationStatus, registrationDate, registrationArea, certificateAvailable] + verification-result: + properties: [eventReference, eventType, registrationStatus, certificateAvailable] + + operations: + read: + access: + scope: registry:civil-events:read + purpose: + claim: purpose + allowed: [civil-registration-administration] + authorityRowBinding: + claim: jurisdiction + sourceColumn: jurisdiction_code + disclosureProfile: registrar-record + lookups: + - id: verify-registration + access: + scope: registry:civil-events:lookup + purpose: + claim: purpose + allowed: [registration-verification] + authorityRowBinding: + claim: jurisdiction + sourceColumn: jurisdiction_code + requestBody: + maximumBytes: 384 + selectors: + registrationNumber: {sourceColumn: registration_number, type: string, minimumBytes: 12, maximumBytes: 96} + eventType: {sourceColumn: event_type, type: controlled-code, codelist: codelists/civil-event-types.yaml} + disclosureProfile: verification-result + + processingDescriptions: + - id: registrar-administration + operationRefs: [read] + purpose: civil-registration-administration + recipientClass: authorized-registrar + legalBasisRef: governance/civil-registration-legal-basis.yaml + dpvProfileRef: governance/civil-event-administration.dpv.yaml + safeguards: [no-collection-list, operation-scopes, principal-row-binding, property-minimization, value-free-audit] + - id: registration-verification + operationRefs: ["lookup:verify-registration"] + purpose: registration-verification + recipientClass: authorized-verifier + legalBasisRef: governance/civil-registration-legal-basis.yaml + dpvProfileRef: governance/civil-event-verification.dpv.yaml + safeguards: [no-collection-list, operation-scopes, principal-row-binding, minimum-disclosure-profile, value-free-audit] + +metadataVisibility: + service: public + resources: operation-bound + semantics: operation-bound + classifications: operator-only + processing: operation-bound + +--- +apiVersion: relay.registrystack.org/v2alpha1 +kind: RelayRuntime +server: {bind: "127.0.0.1:8080"} +packagePath: /srv/relay/civil-events-package +sources: + events: {path: /srv/registries/civil-events.sqlite} +authentication: + issuer: + id: civil-registry-authorization-server + discoveryUrl: https://identity.example.invalid/.well-known/openid-configuration + audience: relay-civil-events + tokenTypes: [at+jwt] + algorithms: [ES256] +audit: + sink: /var/lib/relay/audit/civil-events.jsonl + integrityKeyRef: secret:file/audit-integrity-key +limits: {requestTimeoutMilliseconds: 1500, concurrentQueries: 16} +quotas: {requestsPerMinute: 120, burst: 20} +``` + +## Complete accepted key-path inventory + +The following blocks come from successful typed `relayctl check --production` +reports for all three coequal acceptance projects. They describe the complete +strict configuration surface exercised by those projects. Run +`products/relay-v2/scripts/check-configs.sh --write` after an intentional model +change, then review and explain every new path in the examples above. + + +```text +apiVersion +classifications +classifications.handling +classifications.handling.scheme +classifications.handling.version +classifications.institutional +classifications.institutional.scheme +classifications.institutional.version +classifications.privacy +classifications.privacy.scheme +classifications.privacy.version +classifications.provenanceRef +governance +governance.auditOwner +governance.controller +governance.publisher +kind +metadata +metadata.id +metadata.title +metadata.version +metadataVisibility +metadataVisibility.classifications +metadataVisibility.processing +metadataVisibility.resources +metadataVisibility.semantics +metadataVisibility.service +registry +registry.alignmentTargets +registry.alignmentTargets[] +registry.alignmentTargets[].cfrTarget +registry.alignmentTargets[].name +registry.alignmentTargets[].status +registry.alignmentTargets[].version +registry.authoritativeScope +registry.authority +registry.authority.identifier +registry.authority.name +registry.baseUri +registry.identifierLifecyclePolicyRef +registry.name +registry.operator +registry.operator.identifier +registry.operator.name +registry.registryIdentifier +resources +resources[] +resources[].classificationDefaults +resources[].classificationDefaults.handling +resources[].classificationDefaults.institutional +resources[].classificationDefaults.privacy +resources[].classificationDefaults.status +resources[].description +resources[].disclosureProfiles +resources[].disclosureProfiles.* +resources[].disclosureProfiles.*.properties +resources[].disclosureProfiles.*.properties[] +resources[].id +resources[].operations +resources[].operations.list +resources[].operations.list.access +resources[].operations.list.allowUnfiltered +resources[].operations.list.disclosureProfile +resources[].operations.list.filters +resources[].operations.list.filters[] +resources[].operations.list.filters[].name +resources[].operations.list.filters[].property +resources[].operations.list.filters[].type +resources[].operations.list.orderBy +resources[].operations.list.orderBy[] +resources[].operations.list.pagination +resources[].operations.list.pagination.defaultPageSize +resources[].operations.list.pagination.maximumPageSize +resources[].operations.lookups +resources[].operations.lookups[] +resources[].operations.lookups[].access +resources[].operations.lookups[].access.authorityRowBinding +resources[].operations.lookups[].access.authorityRowBinding.claim +resources[].operations.lookups[].access.authorityRowBinding.sourceColumn +resources[].operations.lookups[].access.purpose +resources[].operations.lookups[].access.purpose.allowed +resources[].operations.lookups[].access.purpose.allowed[] +resources[].operations.lookups[].access.purpose.claim +resources[].operations.lookups[].access.scope +resources[].operations.lookups[].disclosureProfile +resources[].operations.lookups[].id +resources[].operations.lookups[].requestBody +resources[].operations.lookups[].requestBody.maximumBytes +resources[].operations.lookups[].requestBody.selectors +resources[].operations.lookups[].requestBody.selectors.* +resources[].operations.lookups[].requestBody.selectors.*.codelist +resources[].operations.lookups[].requestBody.selectors.*.maximumBytes +resources[].operations.lookups[].requestBody.selectors.*.minimumBytes +resources[].operations.lookups[].requestBody.selectors.*.sourceColumn +resources[].operations.lookups[].requestBody.selectors.*.type +resources[].operations.read +resources[].operations.read.access +resources[].operations.read.access.authorityRowBinding +resources[].operations.read.access.authorityRowBinding.claim +resources[].operations.read.access.authorityRowBinding.sourceColumn +resources[].operations.read.access.purpose +resources[].operations.read.access.purpose.allowed +resources[].operations.read.access.purpose.allowed[] +resources[].operations.read.access.purpose.claim +resources[].operations.read.access.scope +resources[].operations.read.disclosureProfile +resources[].processingDescriptions +resources[].processingDescriptions[] +resources[].processingDescriptions[].dpvProfileRef +resources[].processingDescriptions[].id +resources[].processingDescriptions[].legalBasisRef +resources[].processingDescriptions[].operationRefs +resources[].processingDescriptions[].operationRefs[] +resources[].processingDescriptions[].purpose +resources[].processingDescriptions[].recipientClass +resources[].processingDescriptions[].safeguards +resources[].processingDescriptions[].safeguards[] +resources[].properties +resources[].properties.* +resources[].properties.*.classification +resources[].properties.*.classification.handling +resources[].properties.*.classification.institutional +resources[].properties.*.classification.privacy +resources[].properties.*.classification.status +resources[].properties.*.codelist +resources[].properties.*.description +resources[].properties.*.label +resources[].properties.*.semanticTerm +resources[].properties.*.sourceColumn +resources[].properties.*.sourceRequired +resources[].properties.*.type +resources[].recordContext +resources[].recordContext.lifecycleState +resources[].recordContext.lifecycleState.codelist +resources[].recordContext.lifecycleState.sourceColumn +resources[].recordContext.recordIdentifier +resources[].recordContext.recordIdentifier.sourceColumn +resources[].recordContext.recordedAt +resources[].recordContext.recordedAt.sourceColumn +resources[].recordContext.revisionIdentifier +resources[].recordContext.revisionIdentifier.sourceColumn +resources[].semanticClass +resources[].source +resources[].source.source +resources[].source.view +resources[].sourceColumnClassifications +resources[].sourceColumnClassifications.* +resources[].sourceColumnClassifications.*.handling +resources[].sourceColumnClassifications.*.institutional +resources[].sourceColumnClassifications.*.privacy +resources[].sourceColumnClassifications.*.status +resources[].title +semantics +semantics.alignments +semantics.alignments[] +semantics.alignments[].digest +semantics.alignments[].id +semantics.alignments[].profileRef +semantics.alignments[].relationRequired +semantics.alignments[].version +semantics.localVocabulary +sources +sources.* +sources.*.expectedSchemaFingerprint +sources.*.kind +sources.*.profile +``` + + + +```text +apiVersion +audit +audit.integrityKeyRef +audit.sink +authentication +authentication.issuer +authentication.issuer.algorithms +authentication.issuer.algorithms[] +authentication.issuer.audience +authentication.issuer.discoveryUrl +authentication.issuer.id +authentication.issuer.tokenTypes +authentication.issuer.tokenTypes[] +cursor +cursor.integrityKeyRef +cursor.maximumAgeSeconds +kind +limits +limits.concurrentQueries +limits.requestTimeoutMilliseconds +packagePath +quotas +quotas.burst +quotas.requestsPerMinute +server +server.bind +shutdown +shutdown.gracePeriodMilliseconds +sources +sources.* +sources.*.path +``` + + +What this example must prove: + +- the absence of `list` prevents collection enumeration for every client; +- read and lookup scopes are independent and cannot be substituted for one another; +- the lookup disclosure is smaller than the registrar disclosure, and both can be narrowed further by the requester; +- issuer-assigned audience, scope, purpose, and jurisdiction authority all use the one standard verifier path; a conforming Mint token may use that path without a Mint-specific branch; +- Relay returns an unsigned registry response, while Evidence may use the fixed verification lookup as a source when a signed assertion is needed; +- capability discovery derives `consultation.retrieve` and constrained `consultation.search`, not Record Match or Evidence-family support. + +## Design observations from the examples + +The three examples suggest a compact core model: + +```text +registry contract + -> source reference and reviewed view + -> resource and published properties + -> compiled operations + -> maximum disclosure profile + -> optional requester property subset + -> access constraints + -> semantics, classification, and processing description + -> deterministic query, response, revision, and audit evidence +``` + +The examples also freeze these boundaries: + +- Registry Manifest projection is later portability tooling, not a runtime input; +- Registry Core fields are native and cannot be removed; +- `fields` is one comma-separated property syntax across list, read, and lookup; +- a live source remains useful for read and lookup but is unversioned, `no-store`, and has no paginated list; +- handling uses `public`, `internal`, `confidential`, and `restricted`, while purpose and row binding remain explicit constraints; +- one maximum disclosure profile is compiled per operation, so different operations may differ but caller-dependent variants within one operation are deferred; +- Mint and external issuers use one strict Relay JWT access-token profile; +- generated capabilities and a maintained alignment note describe the written draft standards without consuming their legacy OpenAPI or claiming conformance. diff --git a/products/relay-v2/DEFINITION-OF-DONE.md b/products/relay-v2/DEFINITION-OF-DONE.md new file mode 100644 index 000000000..214862aa9 --- /dev/null +++ b/products/relay-v2/DEFINITION-OF-DONE.md @@ -0,0 +1,172 @@ +# Relay V2 Definition of Done + +Status: Approved acceptance contract +Date: 2026-08-09 +Product direction: [Relay V2 Product Concept](CONCEPT.md) +Configuration design probes: [Relay V2 Configuration Examples](CONFIGURATION-EXAMPLES.md) + +GovStack Digital Registries and API Design Guide drafts are directional inputs. +The legacy Digital Registries OpenAPI is not an acceptance artifact. This DoD +requires a concise alignment note and intentional-difference records, not a +GovStack conformance or certification claim. + +## Completion rule + +Relay V2 is done only when every required row below passes on the same revision. A working SQLite endpoint, one successful registry, generated OpenAPI, or a green subset of tests is not completion. + +The social, business, and civil-event registries are coequal acceptance definitions. None is the architectural seed, a privileged demo, or a later generality check. Production code, public schemas, routes, and CLI behavior remain registry-domain neutral. + +No required behavior may remain as a stub, TODO, undocumented manual step, disabled test, or follow-up issue. Additional storage engines, SpatiaLite, GeoJSON, general policy evaluation, response signing, dynamic masking, and other future profiles are outside this Definition of Done. + +## Coequal acceptance definitions + +| Registry | Required shape | What it must prove | +|---|---|---| +| Social assistance enrolment | Live SQLite, exact lookup only, protected properties, trusted purpose, authority-to-row binding, external authorization server | A sensitive person-related registry can answer a bounded consultation without enumeration, identifier read, selector disclosure, or domain-specific runtime behavior. | +| Business registration | Snapshot SQLite, anonymous public list and identifier read, predefined exact filters, pagination, public semantics | A genuinely public register can be discoverable and cacheable while remaining contract-bound rather than becoming a generic database API. | +| Civil event registration | Live SQLite, protected identifier read plus named exact lookup, different operation scopes and disclosure profiles, optional Mint issuer | A CRVS-shaped event register can support registrar and verification uses without exposing a collection, coupling Relay to Mint, or moving signed assertions into Relay. | + +Each definition must pass offline fixture evaluation and the real HTTP runtime +as its own one-Registry deployment. All three must use the same compiler, +access-decision types, SQLite executor, disclosure planner, serializers, audit +vocabulary, and problem model. Focused parameterized compiler and runtime tests +prove in-process resource isolation without adding a fourth deployment project. + +## Definition of Done + +| Area | Done when | +|---|---| +| Product boundary | One `relay` runtime and one separate `relayctl` implement the initial product. Relay is a read-only governed registry publisher, not a SQL proxy, write API, policy engine, credential issuer, or signed-assertion service. The existing `registryctl` is unchanged and unused by Relay V2. | +| Governed contract | A concise, closed, versioned authoring contract defines resources, source views, identifiers, properties, operations, disclosure profiles, semantics, classifications, access rules, bounds, and metadata visibility, with optional governance sidecars. Unknown fields are rejected. A deployment file may bind paths, listeners, one issuer, secrets, and audit storage but cannot override governed behavior. | +| Registry identity | One contract and process describes exactly one Registry with a globally stable `registryIdentifier`, name, Registry Authority, optional operator, authoritative scope, base URI, and pinned alignment targets. Resources are Record types within that Registry. Authority, controller, publisher, and operator remain distinct roles even when one institution fills several. | +| Registry Core | Every returned Record contains non-selectable `registryIdentifier`, `recordIdentifier`, `revisionIdentifier`, `lifecycleState`, `schemaReference`, `semanticModelReference`, `authorityIdentifier`, `recordedAt`, and selected `domainData`. Record identifier, revision, lifecycle, and recorded time are source-bound; recorded time is never Relay observation time. The Registry and Record identifier pair is stable, and the contract names the institution's identifier-lifecycle policy without claiming Relay can prove non-reassignment from one current database. | +| Family capabilities | Each compiled operation carries a derived family and pattern: read is `consultation.retrieve`, list is `consultation.list`, and named exact lookup is constrained `consultation.search`. Capability discovery is generated from operations rather than separately authored. Relay makes no claim for Record Match or the Provisioning, Evidence, Write, Notification, Aggregate Data, Access Transparency, or Identity Federation families. | +| Compilation and activation | Relay compiles and validates the complete contract before listening, produces one deterministic contract revision, and activates it atomically. Incomplete semantics, unclassified published properties, invalid source bindings, schema drift, conflicting operations, or unsafe access rules prevent readiness. There is no partial activation, runtime merge, silent fallback, or hot reload. | +| Domain neutrality | Production Rust, public configuration schemas, routes, CLI options, and generated generic contracts contain no social-registry, business-registry, CRVS, birth, death, household, benefit, company, or acceptance-fixture-specific type, branch, feature, or operation. Such terms appear only in examples, fixtures, and explanatory documentation. | +| SQLite source boundary | Snapshot and live read-only profiles use one hardened SQLite boundary with OS and engine read-only enforcement, defensive authorizer rules, bound values, consistent per-request transactions, schema fingerprints, and step, time, row, cell, response, queue, and concurrency limits. Writes, schema changes, control statements, attachment, extension loading, and unreviewed SQL are impossible through the public contract. | +| Snapshot profile | A deployment-bound snapshot is captured outside the contract package, bound to its exact file identity and digest, refuses unsafe sidecars or path replacement, opens immutably, and reports a truthful source revision. Identical governed package and snapshot inputs produce identical revisions and generated artifacts. Snapshot mode is supported but not required for a deployment. | +| Live profile | A live database is opened read-only while a separately trusted publisher may update it. Each response uses one consistent read transaction and verifies the expected schema fingerprint. Version one live sources compile read and exact lookup only, always return `sourceRevision: {profile: live, status: unversioned, value: null}`, use `no-store`, and emit no ETag. Publisher-owned revisions, live pagination, and live caching are deferred. | +| Closed operation model | Resources compile only declared list, identifier-read, and named exact-lookup operations. A list's access rule determines whether enumeration is public or protected; absence of list means no enumeration. Collection filters are direct publisher-defined camelCase query parameters, typed, non-personal, and exact-equality only. Any non-empty subset of declared filters is valid, and the contract separately permits or forbids unfiltered access. `pageSize`, `cursor`, and `fields` are reserved. Lookups have complete bounded body inputs and exactly zero or one disclosed result. Callers cannot add SQL, source columns, joins, expressions, operators, paths, projection expressions, sort orders, or page traversal. | +| Disclosure and requester minimization | Every operation selects one reviewed disclosure profile whose `properties` list is both maximum and default. A caller may request a non-empty, duplicate-free comma-separated subset of selectable `domainData` property keys and nothing else. Registry Core fields remain present. Unknown, internal, source-column, or malformed selections fail before source access. Field selection cannot change predicates, bindings, derivations, validation, authorization, effective handling, audit, quota, metadata, or cache posture. Caller-dependent maximum entitlement variants are explicitly deferred. | +| HTTP and pagination | Business routes are under `/v2`; `/health`, `/ready`, and `/openapi.json` are unversioned. `GET /v2` publishes safe first-class Registry service metadata and visible derived capabilities. Lists accept bounded `pageSize` and opaque `cursor` and return `{items, pageInfo: {nextCursor}, meta}` with nullable `nextCursor`. Cursor integrity binds revisions, operation, filters, fixed order, field set, authorization context, and expiry; each page is reauthorized. Single reads and resolved lookups return `{data, meta}`. No caller sorting exists. | +| Query and serialization minimization | Relay may read the complete fixed reviewed projection so it can validate the authoritative Record before disclosure. Unrequested and hidden columns are never serialized. Ordinary JSON and JSON-LD disclose the same Registry Core and selected domain data with deterministic property order. JSON-LD adds the generated context and a derived `@id` without replacing `recordIdentifier`. Cacheable public snapshot responses use a strong exact-byte ETag, `Vary: Accept, Authorization`, `If-None-Match`, and `304`; non-public and live responses are `no-store` and have no ETag. | +| Semantic contract | Every resource and property has a stable local semantic identity, datatype, cardinality, label, and description. `relayctl` can generate reviewed starter semantics, JSON-LD context, permitted-representation JSON Schema and SHACL, full-record validation schema and SHACL, and codelist scaffolding without requiring prior semantic-web expertise. The representation artifacts require Registry Core and validate selectable domain properties only when present; full-record artifacts retain source requiredness. `semanticModelReference` resolves to the generated vocabulary/model and the context is linked separately. Generated suggestions are visibly non-authoritative until accepted. | +| External semantic alignment | Optional mappings to SEMIC, PublicSchema, schema.org, or another profile are curated, relation-qualified, versioned, and digest-pinned. Relay fetches no vocabulary and performs no inference at request time. Mapping changes appear in change-impact reports and cannot silently widen disclosure. | +| Classification and processing | Every published property and every reviewed source-view column has an effective reviewed privacy, institutional, and technical-handling classification with provenance and version. Resource defaults reduce repetition; compilation expands defaults and explicit overrides before validation. Simple property columns inherit unless the source is stricter; hidden Registry Core, selector, row-binding, revision, filter, and order columns are accounted for explicitly. Handling is one of ordered `public`, `internal`, `confidential`, or `restricted`; non-public data requires authentication, operation scope, `no-store`, and durable value-free audit, and restricted data cannot be listed. Purpose and row binding remain explicit access constraints. More restrictive or uncertain classification fails closed. Processing descriptions and DPV projections are optional governance sidecars and never runtime policy. | +| Authentication and issuers | Relay acts as an OAuth 2.0 JWT resource server for protected operations and Version one configures exactly one issuer per Registry deployment. Relay strictly verifies issuer, audience, token type, algorithm, key, time, client or subject, token identifier, and scope claims. Missing or invalid credentials return safe `401` responses; insufficient scope returns `403`. Anonymous access exists only on operations explicitly compiled as public. | +| Operation authorization | List, read, and named lookup use distinct registered scopes that are unique across the Registry contract. Trusted purpose and authority-to-row binding are optional compiled constraints and can come only from the resolved principal or a direct verified scalar claim. Caller filters or headers never create authority. A lookup-only client cannot enumerate or perform identifier reads, even when another client can use those operations on the same deployment. | +| Optional Mint pairing | Relay accepts a conforming token from an external authorization server without Mint. A Mint deployment may be paired when it emits the same Relay audience, operation scopes, and optional authority claims from server-side grants. Relay has no Mint runtime dependency or Mint-specific authentication branch. Mint changes and a Mint integration journey do not block the core Relay V1 acceptance path. | +| Lookup containment | Sensitive selectors use a bounded request body, are bound rather than rendered, and never appear in URLs, errors, logs, metrics, traces, audit, or responses. No match, ambiguity, policy-hidden record, unknown or protected identifier, and unsafe source record share one `404` outcome with the same Registry Stack problem type, code, detail, schema, and headers. Only independently generated trace correlation may differ. Invalid syntax is a value-free bounded request error. Rate and concurrency limits make consultation abuse observable and bounded. | +| Validation and failure | Every selected row is schema-validated before release. Relay never skips, coerces, truncates, or partially releases an invalid row to preserve success. Errors use the fixed Registry Stack status/code catalog and derived type URIs plus `traceId`, exactly the 32-lowercase-hex trace ID of the effective valid or server-generated W3C Trace Context. Caller-supplied `tracestate` is never propagated. Problems contain no field-error array, SQL, paths, schema internals, selector values, source values, token material, or subject identifiers. Draft GovStack error namespaces are not used. Required audit or other release-gate failure prevents disclosure. | +| Unsigned response boundary | Relay responses are not signed. TLS and access-token verification protect the live exchange, while revisions, ETags, provenance, and tamper-evident audit support accountability without being described as signatures. Evidence can consume a fixed Relay lookup when a portable signed minimum-disclosure assertion is required. | +| Audit and provenance | Every public or protected data request processed by Relay durably records either a refusal before returning or a pre-source attempt followed by one terminal release or unresolved outcome. Durable audit gates source access and response release. Events carry stable identifiers for Registry, resource, operation, access-rule revision, optional purpose, row-boundary kind, disclosure profile, selected-property set or digest, handling level, contract revision, and truthful source revision. Anonymous calls record an anonymous principal kind. Audit contains no tokens, selector values, source values, response values, SQL, or raw subject identifiers. The safeguards report names public shared-cache hits as outside Relay observation. | +| Metadata visibility | Registry service identity is public. Other resource, capability, OpenAPI, semantic, classification, processing, and operational metadata is `public`, `operation-bound` behind the same static gate as the operation whose Record links it, or `operator-only` in package/CLI with no HTTP route. Protected resource existence and selector shape are indistinguishable from unknown, and discovery performs no source query. The package contains full OpenAPI; `/openapi.json` is a deterministic safe public projection. Compilation fails if any successful Record audience cannot resolve a safe operation-bound projection of its exact `schemaReference` and `semanticModelReference`. | +| Freshness and caching | Snapshot and live responses expose truthful, profile-specific revision and cache behavior. Every public snapshot response uses `Cache-Control: public, no-cache`, a strong exact-byte ETag, and revalidation. Every non-public or live response is `no-store`. `Vary: Authorization` prevents an anonymous cached `200` from serving a request with an invalid bearer. No response implies a stable cross-request snapshot, and field subsets cannot collide. | +| Generated contracts | OpenAPI 3.1, JSON Schema, SHACL, JSON-LD contexts, codelists, and capability discovery are generated reproducibly from the compiled contract. Full and public OpenAPI projections have drift checks. No artifact is generated from the obsolete Digital Registries OpenAPI. | +| Standards alignment | A concise maintained note pins the reviewed Digital Registries and API Design Guide drafts, maps adopted concepts and Consultation patterns, and records intentional gaps and rejected rules. It uses alignment language only, never conformance or certification. Machine-readable alignment reports, GovStack linting, Registry Manifest projection, and DPV generation are later optional tooling. | +| `relayctl` adopter journey | An adopter can initialize a project, inspect a SQLite schema without values by default, generate starter semantics and classifications, validate, generate artifacts, run fixtures, inspect a semantic and disclosure diff, and package a deployment without editing Rust. `relayctl` uses the same Relay compiler and fixture library as `relay` and implements no second product semantics. | +| Change impact and safeguards | A contract diff identifies new properties, wider operations or filters, relaxed classification, changed disclosure profiles, removed row bindings, expanded scopes or purposes, changed metadata visibility, source-view changes, and semantic mapping changes. Each applicable DPI safeguard is linked to a concrete mechanism, enforcement point, negative test, evidence artifact, and named institutional responsibility. No certification claim is generated. | +| Operability | Unauthenticated `/health` reports only liveness and `/ready` reports only ability to serve the compiled Registry, each as minimal `application/json` on `200`, `no-store`, and safe Registry Stack Problem `503` on failure. Neither exposes Registry or source details. Startup, shutdown, bounded concurrency, audit durability, issuer-key refresh, source unavailability, schema drift, and live publisher replacement have documented and tested behavior. Relay emits structured value-free lifecycle logs and bounded request outcomes using only a fixed method class, route template, status, latency, and trace identifier. Version one has no `/metrics` route or in-process metrics registry; operators derive aggregate metrics outside Relay from these logs and durable audit without protected values or high-cardinality subject labels. | +| Verification evidence | Focused positive, negative, boundary, and non-disclosure tests pass for every security-sensitive behavior. Formatting, package check, Clippy with warnings denied, package tests, workspace tests, dependency policy, contract drift, exposure inventory, source neutrality, config-key-path, and reproducible-generation gates pass on one revision. CI path selection is itself tested for every new owning path. | +| Stop boundary | Version 1 contains no generic storage trait before a second adapter, SpatiaLite or GeoJSON path, general search language, fuzzy or Record Match behavior, dynamic masking, caller-dependent maximum entitlement profile, PDP, consent workflow, response signing, credential lifecycle, multi-source analytics, runtime vocabulary fetch, RDF store, SPARQL, hot reload, write API, registry administration, formal GovStack compatibility mode, or compatibility work in `registryctl`. | + +## Required acceptance coverage + +A compact scenario table binds the three journeys below to executable tests. A +separate security-invariant matrix names threats, enforcement points, and +negative tests. Non-security prose does not require one machine-readable row +per sentence. + +### Cross-registry path + +The three coequal Registry journeys prove adopter-facing behavior. Shared +product-neutral kernel and multi-resource tests prove security invariants that +do not need to be repeated with domain-specific fixtures. + +For each of the three coequal registries: + +1. the example compiles through the same closed configuration types; +2. Registry identity, authority, scope, alignment targets, and derived Consultation capabilities are correct; +3. offline fixtures and the real HTTP service return the same semantic result; +4. every returned Record has valid Registry Core context, and `recordedAt` and `revisionIdentifier` come from the source view; +5. ordinary JSON and JSON-LD are data-equivalent and validate against generated contracts; +6. default disclosure and at least two valid `domainData` subsets succeed while Registry Core remains complete; +7. an unknown property, source-column name, duplicate property, and malformed selection fail without source or value leakage; +8. invalid selected source rows fail the whole response closed; every Registry proves at least one such refusal, and the coequal suite covers wrong type, missing required value, extra unexpected value, and excessive size; +9. restarting with identical inputs reproduces the same compiled contract and generated artifacts; +10. a schema or governed-contract change is detected and cannot silently widen the active API; +11. full packaged OpenAPI, safe public OpenAPI, semantic, schema, SHACL, codelist, and capability artifacts reproduce byte for byte. + +Shared security acceptance additionally proves that: + +1. a response and its emitted value-free audit correlate through trace and request-operation identifiers and agree on Registry, resource, compiled operation, contract, disclosure profile, selected properties, row-boundary kind, and truthful source revision; +2. audit never records response bytes, response digests, Record identifiers, raw subject identifiers, or fixture canaries; +3. Problems do not contain fixture canaries, trace headers carry only Relay-validated fixed identifiers, and operational log dimensions cannot contain request paths, identifiers, query values, headers, bodies, selectors, or principals; +4. adopter reports and generated or packaged artifacts pass fixture-canary scans. + +Raw source databases are governed inputs rather than diagnostic output. Relay +does not claim that a test framework's own failure renderer is a protected +product surface. Metrics, when deployed, are derived externally from the fixed +value-free operational log dimensions. + +### Social registry cases + +- exact match, no match, ambiguity, policy-hidden row, and invalid selected row; +- correct purpose and service-area row binding, missing purpose, wrong purpose, missing binding, and wrong binding; +- lookup scope succeeds while list and identifier read are absent regardless of token scope; +- selectors and internal person, household, and service-area binding columns remain absent from every response and diagnostic surface; +- live update within the compatible schema appears under a truthful later Record revision without mixing rows inside one response; +- the deployment advertises constrained `consultation.search` only and makes no Base Registry or Record Match claim. + +### Business registry cases + +- anonymous paginated list and identifier read over a captured snapshot; +- `pageSize`, first page, cursor page, and nullable `pageInfo.nextCursor` behavior; +- no filter when allowed, each declared direct camelCase exact filter, a subset of declared filters, unknown filter, unsupported operator, and attempted arbitrary sort; +- deterministic ordering and pagination with no duplicate or missing record across the unchanged snapshot; +- public field subset, JSON-LD context, SEMIC mapping artifact, SHACL, and codelist validation; +- snapshot digest, path replacement, unsafe sidecar, write attempt, and schema mismatch failures. +- `consultation.list` and `consultation.retrieve` discovery with no unsupported family claim. + +### Civil-event registry cases + +- protected identifier read and named exact verification lookup, with collection listing absent; +- registrar read scope cannot be inferred from verification lookup scope, and vice versa; +- the registrar and verification operations receive their different compiled disclosure profiles, each safely narrowable by the requester; +- the external-issuer path is complete; a later optional Mint pairing must traverse the same verifier and access-decision path; +- no match, ambiguity, jurisdiction-hidden row, invalid event record, wrong purpose, and wrong jurisdiction binding collapse according to the lookup contract; +- the fixed Relay lookup remains an ordinary protected HTTP source contract + suitable for a future Evidence integration, without adding signing behavior + to Relay; a real Evidence pairing is a separate non-blocking journey. +- the same Registry Core fields remain present under both operation-specific disclosure profiles. + +### Cross-product and neutrality cases + +- three independently instantiated one-Registry services exercise real loopback + HTTP without sharing authority, contract, source, or audit state, and one + packaged deployment passes a real-process start, request, stop, and restart + smoke test; +- parameterized multi-resource compiler and runtime tests prove that contract, query, disclosure, audit, limit, and response state do not cross resource boundaries; +- a repository boundary check rejects acceptance-domain terms and branches from production code and generic public schemas; +- the same hardened SQLite executor serves snapshot and live profiles without domain-specific SQL paths; +- a token minted for Evidence, another Relay audience, or an undeclared operation is rejected; +- a public operation in a focused multi-resource test does not weaken a protected resource in that same process; +- full packaged OpenAPI contains all operations while public OpenAPI omits every protected selector and operator-only artifact; +- unknown, protected, ambiguous, and unsafe lookup outcomes are identical except for trace correlation; +- the alignment note records the deferred same-operation entitlement variant and every unimplemented family without a conformance claim; +- every security invariant has a named threat, enforcement point, stable negative case, and traceable test. + +## Completion evidence + +Before the product can be called complete, the repository must contain and CI must invoke: + +- a compact scenario table for the three acceptance definitions; +- a security-invariant matrix paired with executable negative-test traceability; +- reproducible generators and drift checks for public and semantic artifacts; +- generated Consultation capabilities plus a maintained Digital Registries and API Design Guide alignment note; +- source-product-neutrality and protected-value canary scans; +- focused runtime, `relayctl`, shared-SQLite, issuer, audit, and serialization tests; +- the applicable package and workspace formatting, check, Clippy, test, and dependency-policy gates; +- one local end-to-end journey for each coequal registry using synthetic SQLite data and no external credentials. + +Optional live demos may supplement this evidence but never replace deterministic local fixtures and tests. diff --git a/products/relay-v2/IMPLEMENTATION.md b/products/relay-v2/IMPLEMENTATION.md new file mode 100644 index 000000000..984b4ffd4 --- /dev/null +++ b/products/relay-v2/IMPLEMENTATION.md @@ -0,0 +1,699 @@ +# Relay V2 Implementation Plan + +Status: Approved implementation plan +Date: 2026-08-09 +Product direction: [Relay V2 Product Concept](CONCEPT.md) +Acceptance contract: [Relay V2 Definition of Done](DEFINITION-OF-DONE.md) +Configuration probes: [Relay V2 Configuration Examples](CONFIGURATION-EXAMPLES.md) + +## Delivery rule + +Implementation starts from the latest `origin/main` in a dedicated worktree. +Relay V2 is added beside the maintained Relay V1. The existing +`crates/registry-relay`, its release artifacts, and `crates/registryctl` remain +unchanged except where a workspace-wide shared-platform dependency requires +ordinary lockfile or CI routing updates. `registryctl` receives no V2 command, +compatibility shim, deprecation, or migration work. + +The product is complete only when the full Relay V2 Definition of Done passes +for the social, business, and civil-event acceptance deployments on one +revision. Milestones may merge independently when their own boundary is +complete and green, but no partial milestone is described as Relay V2 complete. + +## Target architecture + +### Owning packages + +| Package | Boundary | +|---|---| +| `registry-relay-v2` | New library and final `relay` binary. Owns the strict Relay contract, compiler, generated artifacts, fixture kernel, access and disclosure plans, representations, HTTP service, and Relay event/problem vocabularies. It has no dependency on Relay V1. | +| `registry-relayctl` | New `relayctl` binary. Owns authoring presentation and orchestration only. It links the shared Relay compiler library and never reimplements its rules. | +| `registry-platform-sqlite` | New product-neutral SQLite security boundary shared by Evidence and Relay. It is SQLite-specific, not a generic storage abstraction. | +| `products/relay-v2` | Canonical concept, contract schemas, examples, fixtures, generated artifacts, compact scenario and security traceability, alignment note, and drift scripts. | + +`registry-relay-v2` is an internal coexistence name. Its shipped command is +`relay` from the first milestone. V1 retains the `registry-relay` package name +until its separately governed retirement. + +### Runtime structure + +The runtime crate is organized by responsibility rather than registry domain: + +```text +contract -> compile -> immutable CompiledRegistry -> artifacts + | +request -> authentication -> AccessDecision -> DisclosurePlan + | + generated SQLite plan -> validated Record + | + JSON or JSON-LD -> release audit -> HTTP bytes +``` + +The HTTP service and offline fixtures call the same compiled kernel. Production +code contains no social, business, company, benefit, household, birth, death, +or CRVS branch. There is one compiled Registry and one administrative trust +domain per process, with any number of related resources under that Registry. + +## Frozen interfaces + +### Governed and deployment inputs + +`RegistryContract` is strict YAML with duplicate and unknown keys rejected. It +owns: + +- contract identity and version; +- one Registry identifier, name, Registry Authority, optional operator, + authoritative scope, base URI, identifier-lifecycle policy, and pinned + alignment targets; +- reviewed SQLite sources and views; +- resources, Registry Core source bindings, URL-safe camelCase property keys, + published properties, datatypes, source requiredness, codelists, labels, + descriptions, and local semantic IRIs; +- compiled list, read, or named exact-lookup operations; list presence and its + access rule derive the enumeration posture; +- direct typed equality filters, explicit unfiltered permission, fixed ordering, + page bounds, lookup selectors, and query limits; +- one disclosure profile per operation whose `properties` list is both maximum + and default; all Version one operations permit callers to narrow `domainData` + with `fields`; +- privacy, institutional, and `public`/`internal`/`confidential`/`restricted` + technical handling classifications for every published property and reviewed + source-view column, including status, provenance, and version, with reviewed + resource defaults expanded before validation; +- operation scopes, verified-purpose constraints, authority-to-row bindings, + optional processing sidecars, and metadata visibility. + +Every resource binds `recordIdentifier`, `revisionIdentifier`, +`lifecycleState`, and `recordedAt` to reviewed source-view columns. Registry, +authority, schema, and semantic-model references derive from the contract. +Version 1 supports only `string`, `boolean`, `integer`, RFC 3339 `date`, RFC +3339 `date-time`, and `controlled-code` domain properties. Every reviewed view +column must be accounted for as a Record binding, public property, filter, +order key, selector, or row binding. Extra columns fail +compilation. There is no authored SQL and no generic source-adapter trait. + +A one-to-one property column inherits that property's classification unless an +explicit source classification is more restrictive. Every non-property Record +Core, selector, row-binding, revision, filter, and order column declares its own +classification. Unclassified reviewed columns fail production compilation. + +Every source pins `expectedSchemaFingerprint`. Snapshot source revision is the +captured file digest. Version one live sources are unversioned and report +`{profile: live, status: unversioned, value: null}` in response and audit. They +compile read and exact lookup only. Publisher revisions and live pagination are +not part of the Version one contract. + +`sourceRequired` governs validation of the complete authoritative source +Record. It does not make a property mandatory in every requester-minimized +representation. The compiler emits separate full-record validation and +permitted-representation artifacts. The latter requires Registry Core and +validates selectable `domainData` properties when present; the former preserves +source requiredness and full SHACL cardinality. + +`RelayRuntime` is a separate strict deployment file. It binds listener, +`packagePath`, SQLite paths, at most one issuer and audience, secrets, cursor +key, audit sink, timeouts, concurrency, quotas, and +shutdown. +It cannot add or weaken a resource, operation, disclosure, access rule, +classification, semantic mapping, or metadata visibility decision. +Audit sink and integrity key are mandatory. There is no `failClosed` switch; +durable refusal, source-access, and response-release gating cannot be disabled +by deployment configuration. + +The shared compiler library's packager, exposed as `relayctl package`, is the +only production packaging path. It creates a deterministic sealed directory +with: + +```text +relay-package.json +registry.yaml +governed/... +generated/openapi.full.yaml +generated/openapi.public.json +generated/artifacts/... +``` + +`relay-package.json` is canonical JSON containing `packageVersion`, +`packageRevision`, `contractRevision`, the expected SQLite schema fingerprint, +the generated-artifact inventory, and for every relative regular file its path, +size, SHA-256 digest, media type, visibility, and generated/authored status. +References cannot escape the directory and symlinks are rejected. The runtime +file, sealed package tree, and their ancestry must be owned by root or the +Relay service user and must not be writable by another account; only a +root-owned sticky directory is accepted as a shared ancestor. Production +packages exclude fixtures and SQLite data. Snapshot and live database paths are +deployment bindings; Relay captures a snapshot digest or explicitly reports an +unversioned live source. `relay serve --runtime ` resolves the sealed +package only from the runtime's `packagePath`; it never accepts a mutable +authoring project or loose contract file. + +The complete governed file closure is captured into memory with file count, +size, path, symlink, and permission bounds before parsing. Canonical typed +inputs produce `contractRevision`. Compilation is atomic and startup-only. +There is no hot reload, partial activation, overlay, fallback, or remote +vocabulary fetch. + +Registry Manifest projection is deferred portability tooling. Source columns, +access rules, scopes, disclosure, classifications, processing constraints, and +limits remain Relay-owned and never enter Manifest. + +### Shared compiler and tooling boundary + +`registry-relay-v2` exposes one library API for parsing, compilation, schema +inspection, generation, fixture evaluation, change classification, and +packaging. Both binaries use it directly: + +- `relay` owns `serve` and runtime diagnostics; +- `relayctl` owns `init`, `inspect`, `check`, `generate`, `test`, `diff`, and + `package` authoring workflows. + +Diagnostics use stable value-free codes and package-relative locations. A +best-effort `--json` rendering supports CI, but it is not a frozen inter-process +protocol in Version one. `relayctl` does not spawn `relay`, parse human output, +or reimplement compiler rules. A process protocol can be added later if an +external consumer needs one. + +### Registry Core and response shapes + +Every successful Record has: + +```json +{ + "registryIdentifier": "urn:example:registry:businesses", + "recordIdentifier": "B-00142", + "revisionIdentifier": "17", + "lifecycleState": "ACTIVE", + "schemaReference": "https://registry.example/v2/artifacts/business.schema.json", + "semanticModelReference": "https://registry.example/v2/artifacts/business.vocabulary.jsonld", + "authorityIdentifier": "urn:example:authority:registrar", + "recordedAt": "2026-08-01T10:30:00Z", + "domainData": {} +} +``` + +The Registry and Record identifier pair is authoritative. JSON-LD adds a +derived global `@id` but retains both identifiers. Lifecycle values come from +the resource's governed codelist. `recordedAt` is source-owned and is never +Relay observation time. + +Single read and resolved lookup responses use `{data, meta}`. Lists use: + +```json +{ + "items": [], + "pageInfo": {"nextCursor": null}, + "meta": {} +} +``` + +Every Record data response `meta` uses this compact vocabulary. Serialization +is deterministic for validators, but member order is not a client contract: + +```json +{ + "operationIdentifier": "registeredBusiness.list", + "family": "consultation", + "pattern": "list", + "disclosureProfile": "public-register", + "contractRevision": "sha256:...", + "sourceRevision": { + "profile": "snapshot", + "status": "versioned", + "value": "sha256:..." + }, + "selectedFields": ["registrationNumber", "legalName"], + "links": { + "self": "https://registry.example/v2/resources/registered-business/records", + "context": "https://registry.example/v2/artifacts/business.context.jsonld", + "schema": "https://registry.example/v2/artifacts/business.schema.json", + "semanticModel": "https://registry.example/v2/artifacts/business.vocabulary.jsonld" + } +} +``` + +`family` is always `consultation`; `pattern` is `retrieve`, `list`, or +`search`. `sourceRevision.status` is `versioned` or `unversioned`; `value` is +null only for unversioned live data. `selectedFields` is always present in +contract order, including when the caller omitted `fields`. No other member is +nullable. `semanticModelReference` points to the local vocabulary/model while +`links.context` points to the JSON-LD context. `meta` contains no selector, +row-binding value, raw principal, token, SQL, or protected source value. + +Where caching is allowed, a strong ETag hashes the exact response bytes, so +field subsets and the two representations have different ETags. `Vary: Accept, +Authorization`, `If-None-Match`, and `304` are part of the GET contract. +Non-public and unversioned-live responses are `no-store` and emit no ETag. + +`fields` is a comma-separated, non-empty, duplicate-free list of public +property keys matching `^[a-z][A-Za-z0-9]*$`. Exactly one `fields` query +parameter is allowed; whitespace, empty members, repeats, semantic IRIs, and +source columns are invalid. It changes only `domainData`, preserves contract +order rather than request order, and is validated before source access. Hidden +columns may still be read for +predicates, binding, complete row validation, and truthful revisions, but are +never serialized. + +Narrowing fields never lowers the operation's compiled handling level, +authentication, durable audit, quota, metadata visibility, or cache posture. + +A named lookup body is exactly: + +```json +{"selectors":{"caseReference":"C-123","personReference":"P-456"}} +``` + +The top level contains only `selectors`; that object contains exactly the +compiled selector keys. Duplicate or unknown members, missing keys, nulls, +wrong scalar types, coercion, normalization, and extra nesting are rejected. +`fields` remains in the query string and never appears in the body. + +For `application/ld+json`, each response carries `@context` and each Record adds +a derived `@id`. The generated context maps +`domainData` to JSON-LD `@nest`, maps its property keys to their semantic IRIs, +types Registry Core IRI members as `@id`, and maps transport-only `meta` and +`pageInfo` to null so they do not become domain triples. Ordinary JSON retains +the shapes above without `@context` or `@id`. + +### HTTP binding and capabilities + +The initial routes are: + +```text +GET /health +GET /ready +GET /openapi.json +GET /v2 +GET /v2/resources?pageSize=...&cursor=... +GET /v2/resources/{resource} +GET /v2/resources/{resource}/records?pageSize=...&cursor=...&=...&fields=... +GET /v2/resources/{resource}/records/{recordIdentifier}?fields=... +POST /v2/resources/{resource}/lookups/{lookup}?fields=... +GET /v2/artifacts/{artifactIdentifier} +``` + +`GET /v2` returns the closed service document +`{registryIdentifier, name, authority, operator, authoritativeScope, product, +apiBinding, alignmentTargets, capabilities, links}`. `product` and `apiBinding` +each contain `name` and `version`; `capabilities` contains only visible +`{family, pattern, resourceIdentifier, operationIdentifier, href}` entries. +`operator` is present and nullable when the Registry has not named one. + +`GET /v2/resources` returns `{items, pageInfo, meta}` where each item contains +`resourceIdentifier`, title, description, semantic class, enumeration posture, +visible capabilities, and links, and metadata `meta` contains only +`registryIdentifier`. `GET /v2/resources/{resource}` returns the same resource +object under `{data, meta}`. These metadata envelopes have their own generated +schemas and do not use Record response metadata. Artifact routes +return their declared media type directly rather than a JSON envelope. + +`GET /v2` is public Registry service metadata. Its generated schema contains +Registry identifier, name, Authority, operator, authoritative scope, product +and API binding versions, pinned standards and CFR alignment targets, visible +derived Consultation capabilities, and links. Resource and operation details +remain visibility-gated. The maintained alignment note records any intentional +API-guide difference. + +Only configured data operations exist. Metadata and artifact responses obey +compiled visibility. `{recordIdentifier}` is opaque, URL-safe, and not a +personal selector. Named lookup bodies are strict, size-bounded, and naturally +idempotent; the query reads at most two rows to distinguish one result from an +unresolved condition. + +Operation-bound metadata is exposed only when the caller satisfies the same +static scope, purpose, and authority-claim gate as the operation whose Record +links it. Separate operation profiles receive separate safe artifacts where +necessary. An inaccessible protected resource, operation, or artifact uses the +same `resource.not_found` response as an unknown one and performs no source +query. + +Metadata visibility has three executable meanings: + +- `public`: mounted for anonymous GET; +- `operation-bound`: mounted behind the referencing operation's static gate; +- `operator-only`: present only in the sealed package and `relay`/`relayctl` + output, never mounted on the HTTP router. + +Registry service identity remains public. Capability visibility derives from +the operations included in resource metadata; it is not configured separately. + +List filters are direct declared camelCase query parameters, exact-equality +only, non-personal, unique, and cannot be named `pageSize`, `cursor`, or +`fields`. Any non-empty subset of declared filters is valid. The operation +explicitly declares whether the empty subset is allowed with `allowUnfiltered`. +`pageSize` is bounded by the operation default and maximum. Ordering is fixed +with `recordIdentifier` as the unique tie-breaker. +The opaque authenticated cursor binds contract and source revisions, operation, +filters, order, fields, authorization-relevant context, and expiry. Every page +is reauthorized. A caller cannot sort, name a source column, add an operator, or +traverse an uncompiled page. + +The first page accepts `pageSize`, `fields`, and declared filters. A +continuation request supplies exactly one `cursor` parameter and +no `pageSize`, `fields`, or filters; the cursor restores the immutable query +context. Repeating or changing first-page parameters with a cursor is +`query.cursor_invalid`. + +Version 1 quota state is bounded and in-process, so the declared deployment +profile is one Relay replica per Registry. A multi-replica deployment must put +a trusted ingress or shared limiter in front that enforces the same ceilings; +distributed rate-limit state is outside the initial runtime. + +Compiled operations derive their capability mapping: + +- read: `consultation.retrieve`; +- list: `consultation.list`; +- named exact lookup: constrained `consultation.search`. + +The generated capability inventory includes Registry identity and authority, +alignment targets, API binding version, operation and resource IDs, pattern +IDs, schema and semantic links, and metadata visibility. It makes no other +family claim and never calls exact lookup Record Match. + +For every compiled operation, the compiler proves that each audience allowed a +successful Record can resolve safe projections of the exact +`schemaReference` and `semanticModelReference` embedded in it. Making either +mandatory artifact less visible than its Record is a compile error. + +The package contains the full generated OpenAPI 3.1 YAML contract as +operator/package material; it is never mounted on the public router. Public +`/openapi.json` returns `application/json` and is a deterministic safe +projection from the same compiled model, omitting protected resources, lookup +shapes, and operator-only artifacts. It is public, revalidation-cacheable with +a strong ETag, and not filtered per caller. A drift test proves that every +public path is identical in the full artifact and that omissions follow only +compiled visibility. The public projection is reviewed against the maintained +API-guide alignment note; the full artifact is separately validated for +internal completeness. Protected discovery comes through operation-bound +resource and artifact routes. + +### Errors, traces, caching, and rate limits + +V2 problems contain RFC 9457 `type`, `title`, `status`, fixed safe `detail`, +Registry Stack `code`, and W3C `traceId`. Type URIs remain under +`https://id.registrystack.org/problems/registry-relay/` followed by the code +with dots changed to slashes; draft GovStack BB namespaces and codes are not +used. The service accepts `traceparent`, returns the effective context, and +creates one only when the input is absent or invalid. Every Problem `traceId` +is exactly the effective W3C trace ID as 32 lowercase hexadecimal characters. +Caller-supplied `tracestate` is never propagated because Relay cannot establish +that vendor state is value-free. Audit and server logs use the same trace ID. + +No match, ambiguity, policy-hidden record, unknown or protected identifier, and +invalid selected source row return the same `404` problem and headers. Only +independently generated trace correlation may differ. Malformed requests, +credentials, insufficient authority, unsupported representation, body size, +quota, internal failure, source failure, and audit failure use stable separate +Registry Stack codes without reflecting input values. Problems are `no-store`. +`401` includes `WWW-Authenticate`; Relay-owned `429` includes a coarse +`Retry-After`. Version one does not freeze a successful-response `RateLimit` +header contract. +For non-public route space, an unauthenticated request receives the same generic +`401` whether the named resource exists or not. After authentication, +visibility-hidden metadata uses the same `resource.not_found` `404` as unknown +metadata. + +Version 1 does not negotiate `Accept-Language`. Protocol titles and fixed +problem details are English; semantic and vocabulary artifacts preserve the +language-tagged labels authored in the contract. The alignment note records +response-language negotiation as a future compatibility gap. + +The Version 1 public taxonomy is fixed as follows. Titles are the title-cased +code meaning and details are exactly the generic text shown. No field-level +error array is emitted. + +| Condition | Status | Code | Fixed detail | +|---|---:|---|---| +| Malformed JSON, query syntax, or lookup body | 400 | `consultation.invalid_request` | `the consultation request is invalid` | +| Invalid, empty, repeated, unknown, or non-public `fields` selection | 400 | `request.fields_invalid` | `field selection is invalid` | +| Undeclared filter | 400 | `filter.unknown_field` | `filter is not declared for this operation` | +| Invalid filter value or combination | 400 | `filter.invalid_value` | `filter value is invalid` | +| Malformed, expired, stale, or differently bound cursor | 400 | `query.cursor_invalid` | `cursor is invalid for this query` | +| Missing credential on a protected operation | 401 | `auth.missing_credential` | `a bearer access token is required` | +| Invalid credential | 401 | `auth.invalid_credential` | `bearer access token validation failed` | +| Insufficient scope, purpose, or row authority | 403 | `consultation.denied` | `the consultation is not permitted` | +| Unknown or visibility-hidden resource or artifact | 404 | `resource.not_found` | `the requested resource was not found` | +| Unknown, hidden, ambiguous, or unsafe Record outcome | 404 | `consultation.unresolved` | `the requested record was not resolved` | +| Unsupported response `Accept` | 406 | `representation.unsupported` | `the requested representation is not supported` | +| Request body too large | 413 | `internal.payload_too_large` | `request body exceeds the configured limit` | +| Request URI too long | 414 | `internal.uri_too_long` | `request URI exceeds the configured limit` | +| Unsupported request body media type | 415 | `request.media_type_unsupported` | `request body must use application/json` | +| Relay consultation quota exhausted | 429 | `consultation.rate_limited` | `the consultation quota is exhausted` | +| Unhandled failure | 500 | `internal.unhandled` | `the request could not be served` | +| Source unavailable or schema drifted | 503 | `source.unavailable` | `the authoritative source is unavailable` | +| Durable audit unavailable | 503 | `audit.unavailable` | `required audit is unavailable` | +| Service not ready or unhealthy | 503 | `service.not_ready` | `the service is not ready` | +| Request deadline exceeded | 504 | `internal.timeout` | `request exceeded the configured timeout` | + +`/health` and `/ready` are unauthenticated and declared with OpenAPI +`security: []`. Their `200 application/json` bodies are respectively +`{"status":"ok"}` and `{"status":"ready"}`. Both are `no-store` and reveal no +Registry, source, issuer, or audit detail. A responding but unhealthy or +unready process returns the `service.not_ready` `503` Problem. + +The serving process writes structured lifecycle events and one bounded outcome +event per HTTP request. Request events contain only the fixed method class, +fixed route template, status, bounded latency, and effective trace identifier; +they never contain raw paths, query strings, headers, bodies, selectors, +Record identifiers, or principal identifiers. Version one adds no `/metrics` +route or in-process metrics registry. Operators derive aggregate service +metrics outside Relay from these value-free logs and the durable audit stream. + +Every public snapshot response uses +`Cache-Control: public, no-cache`, a strong exact-byte ETag, revalidation, and +`304`. Non-public and live responses are `no-store` and emit no ETag. Live +sources compile read and lookup only, never paginated list. +SQLite path replacement fails closed until restart. + +### Authentication, authorization, and audit + +Protected operations accept only a registered JWT access-token profile: + +- exact configured issuer and one exact configured audience; +- `typ=at+jwt`, configured asymmetric algorithm, and issuer-selected key; +- required valid `exp`, `iat`, `nbf`, bounded `jti`, and a bounded lifetime; + Relay stores no token replay state and makes no replay-prevention or + single-use claim; +- principal resolved in order from `sub`, `client_id`, then `azp`, with a + malformed higher-priority claim failing rather than falling through; +- one explicit operation scope plus any compiled purpose and row-binding claim. + +The deployment has at most one issuer verification configuration. Its exact +issuer is checked before its keys can authorize the token. A missing +bearer is allowed only for an explicitly public operation; an invalid bearer is +never treated as anonymous. Caller purpose headers are rejected. Purpose and +row authority come only from verified claims assigned by the authorization +server. Every protected operation scope is non-empty and unique across the +Registry contract. An authority row binding explicitly selects either the +resolved principal or one direct verified scalar claim. Relay injects its +hidden typed equality predicate; caller filters cannot satisfy or replace it. + +Every data operation, including public release, uses durable value-free audit +as a release gate: + +1. append the attempt before source access; +2. append any refusal before returning it; +3. serialize and validate successful bytes; +4. append the release outcome before those bytes leave the process. + +Audit sink failure returns `503` and prevents source access or response release +at the relevant gate. Events contain stable Registry, resource, operation, +access-rule, processing, disclosure, selected-property, handling, contract, and +truthful source revision identifiers. They contain no token, selector, SQL, +path, source or response value, or raw principal identifier. + +Registry Mint is optional. Relay production crates do not depend on Mint. A +Mint deployment may be paired after core V1 when its server-side client grants +can emit Relay's standard audience, scope, optional purpose, and optional +binding claims. Mint never copies requested authority from the caller. This +pairing uses the same verifier and decision path and does not block Relay V1. + +The initial machine-to-machine profile is OAuth client credentials at the +authorization server followed by this JWT access token at Relay. Mutual-TLS +client identity and formal GovStack inter-BB security conformance are deferred; +deployment TLS remains mandatory. + +## Milestones + +### 0. Canonical contracts and gates + +- Import the approved concept, DoD, examples, and this plan into + `products/relay-v2`; add one compact three-registry scenario table and one + machine-readable security-invariant matrix. +- Freeze the authored contract vocabulary, Registry Core response, problem + taxonomy, capability mapping, metadata visibility, token claim profile, + audit event schema, and expected generated-artifact inventory. +- Add source-neutrality, protected-value canary, and artifact reproducibility + scripts before runtime behavior grows. + +Gate: schemas and examples parse; every security row has an owner, enforcement +point, and planned test ID; no Digital Registries OpenAPI is used. + +### 1. Shared SQLite security kernel + +- Add `registry-platform-sqlite` with closed value-free errors, snapshot + capture, immutable read-only open, authorizer, typed results, statement and + pool limits, cancellation recovery, and fixture-only materialization. +- Move the generic hardened implementation and primitive tests from Evidence. + Keep Evidence's statement, `evidence_extract`, freshness, source binding, + response, revision, and error semantics in Evidence. +- Add a behavior-preserving Evidence adapter as a separately reviewable + checkpoint. Relay compiler work may proceed in parallel once the small + platform boundary is frozen; Evidence migration must be green before release, + not before the first Relay slice. + +Gate: focused platform tests and CI routing tests. When the Evidence adapter +lands, run its frozen source tests and contract/source-neutrality scripts. Run +one workspace Rust gate because the root workspace changed. + +### 2. Relay compiler and deterministic artifacts + +- Add `registry-relay-v2` with strict contract/runtime types, closure capture, + diagnostics, source catalog validation, compiled operations, access rules, + disclosures, Registry Core, classifications, and immutable revisions. +- Extend `registry-platform-sqlite` with live read-only binding only now that + Relay is its first consumer: WAL support, a sealed multi-statement read + transaction, canonical per-transaction schema fingerprint verification, + same-file update visibility, and path-replacement refusal. No raw connection + crosses the platform boundary. +- Generate SQL only from reviewed view and column identifiers. Compile a fixed + query plan for every list, read, and lookup operation. +- Implement the shared offline fixture kernel now: bounded SQLite execution, + complete Record validation, disclosure, field narrowing, JSON/JSON-LD + serialization, revisions, and audit-event construction without HTTP. +- Generate local semantic IRIs, JSON Schema, SHACL, JSON-LD context, codelists, + capability discovery, and full/public OpenAPI. +- Expose compilation, inspection, generation, fixture, diff, and package + functions through the shared library API. + +Gate: compiler and live-platform tests, offline kernel and fixture tests for all +three contracts, byte-reproducible artifacts and packages, unsafe or incomplete +contracts failing before evaluation, and explicit full/public OpenAPI drift +tests. + +### 3. `relayctl` adopter workflow + +- Add `registry-relayctl` with `init`, `inspect`, `check`, `generate`, `test`, + `diff`, and `package`. +- Call the shared Relay compiler and fixture library directly for schema + inspection, checking, generation, fixtures, semantic diff, and packaging. +- Keep inspection schema-only by default. Generate local semantics, + classifications, processing, and lifecycle-policy starters as visibly + unreviewed suggestions. A production check rejects unreviewed suggestions. +- Present the authoritative shared-library diff report for security and meaning + changes, including newly exposed properties, operations, filters, relaxed + handling, removed bindings, expanded purposes, metadata visibility, source + views, Record context, and semantic mappings. `relayctl` adds no diff rules. + +Gate: command tests against the shared compiler, deterministic packages, +value-free diagnostics, and one complete authoring journey per acceptance +Registry. No `registryctl` file or command changes. + +### 4. HTTP service over the compiled kernel + +- Mount the already-proven offline kernel behind HTTP and add ETags, cursors, + list/read/lookup routes, Registry service metadata, resource metadata, + artifacts, health, readiness, and safe public OpenAPI. +- Implement exact-lookup collapse, request and concurrency quotas, cursor + reauthorization, schema drift refusal, live transaction consistency, and + classification-aware caching. +- Keep route construction hardcoded from compiled operation kinds. Contract + data chooses which routes exist but cannot supply arbitrary paths or SQL. + +Gate: focused route, cursor, representation, caching, source-boundary, and +non-disclosure integration tests plus generated OpenAPI validation and drift. +The maintained alignment note is reviewed here. A draft GovStack linter is not +a Version one gate. + +### 5. Security and release gates + +- Use one issuer through the existing supported OIDC discovery path. Add only + the strict duplicate-member, exact issuer, algorithm, key, audience, token + type, time, principal, and scope behavior Relay actually needs. +- Complete exact scope confinement, + trusted purpose, row binding, metadata visibility, W3C Trace Context, stable + Registry Stack problems, and durable attempt/refusal/release audit ordering. +- Add named negative tests for token parsing and issuer selection, operation + confinement, public/invalid-bearer behavior, lookup non-enumeration, field + monotonicity, quotas, audit failures, metadata visibility, and protected-value + canaries. +- Run an explicit security-invariant review before accepting this milestone. + +Gate: every security traceability row executes, unknown/protected/unresolved +responses match except trace correlation, audit failures prevent release, and +no credential, selector, record, SQL, path, or raw principal canary appears in +any diagnostic surface. + +### 6. Coequal acceptance and product composition + +- Materialize the social, business, and civil-event examples as separate + synthetic deployment projects and run each through `relayctl`, offline + fixtures, and a real local `relay` process. +- Use focused parameterized compiler/runtime tests for multi-resource state + isolation instead of a fourth deployment project. +- Record the standard token contract Mint must emit. Mint grant changes and a + full Mint pairing are a later independently deliverable integration. +- Record Relay's named lookup as an ordinary protected HTTP source contract for + a future Evidence integration. A real Evidence pairing remains a separate, + non-blocking integration journey; Evidence remains the signer and adds no + Relay authorization model. +- Complete the three-registry scenario table, security matrix, + source-neutrality checks, and concise alignment note. Record the deferred + same-operation entitlement variant rather than simulating support. + +Gate: every DoD row and three acceptance definitions pass on one revision with +no external credentials. Optional live demos may supplement but never replace +local deterministic evidence. + +### 7. Release boundary + +Historic Registry Stack releases and their published manifests are immutable. +Relay V2 artifact, image, SBOM, and provenance publication belongs to the next +unused release train after runtime acceptance, using new artifact identities +rather than overloading V1 Relay or `registryctl` keys. A standalone V2 image +contract and generic source/gate inventory may prepare that work without +claiming a historic release published Relay V2. This is a release ownership +boundary, not unfinished runtime behavior. + +Gate: the runtime acceptance revision passes its focused source, image-contract, +and gate-inventory checks. The owning future release train runs release +validation, source-model, reproducibility, SBOM, and provenance checks when it +publishes the artifacts. + +## Verification policy + +Run the smallest relevant package tests while iterating. Group broader and +advanced checks at the milestones above: + +- platform and Evidence full compatibility at the SQLite extraction boundary; +- contract, schema, security, OpenAPI, and generated-artifact review when those + interfaces freeze; +- all coequal acceptance projects only after the runtime path is complete; +- the full workspace Rust and dependency suite once at final integration, plus + earlier only when a shared platform/root change makes it materially + necessary; release and reproducibility publication checks belong to the next + unused release train. + +Formatters are check-only until the owning patch is ready. OpenAPI and semantic +artifacts are always regenerated by canonical commands, never hand-edited. +Optional demos, broad documentation sweeps, formal GovStack ceremonies, and +future compatibility profiles do not block focused implementation milestones. + +## Explicitly deferred + +- caller-dependent maximum disclosure entitlements within one operation; +- a GovStack compatibility flag, BB problem namespace, or formal conformance; +- any Digital Registries family other than the three declared Consultation + patterns; +- publisher-owned live revisions, live pagination, and live caching; +- multi-issuer selection, new authorization-server discovery modes, and Mint + grant changes; +- a frozen `relay`/`relayctl` subprocess protocol; +- Registry Manifest, DPV, safeguards, and machine-readable GovStack alignment + projections; +- Registry multi-tenancy, generic storage traits, PostgreSQL, SpatiaLite, + GeoJSON, relationships, nested properties, arrays, decimals, search language, + fuzzy matching, and Record Match; +- dynamic masking, general PDP, consent workflow, writes, notification, + aggregate computation, principal-facing access history, response signing, + and credential lifecycle; +- any change to `registryctl`. diff --git a/products/relay-v2/README.md b/products/relay-v2/README.md new file mode 100644 index 000000000..e5b31650c --- /dev/null +++ b/products/relay-v2/README.md @@ -0,0 +1,51 @@ +# Relay V2 product source + +This directory is the tracked product source for the Relay V2 rebuild. It +contains the approved product direction, its completion contract, executable +acceptance inputs, and the small machine-readable catalogs enforced by the +Relay V2 crates and product gates. + +The initial boundary is intentionally narrow: + +- one Relay process serves one governed Registry; +- SQLite is read-only and is the only source profile; +- resources are Record types within the Registry; +- compiled operations map only to Consultation Retrieve, List, and constrained + Search; +- responses are unsigned; +- Registry Mint is optional and Registry Evidence remains a separate product; +- the written GovStack drafts are alignment inputs, not conformance contracts; +- the obsolete Digital Registries OpenAPI is not consumed. + +## Layout + +| Path | Purpose | +|---|---| +| `CONCEPT.md` | Approved product boundaries and architecture. | +| `DEFINITION-OF-DONE.md` | Completion and acceptance contract. | +| `CONFIGURATION-EXAMPLES.md` | Illustrative authoring examples. | +| `IMPLEMENTATION.md` | Approved implementation sequence and verification policy. | +| `STANDARDS-ALIGNMENT.md` | Maintained directional mapping to the pinned GovStack drafts. | +| `contracts/` | Hand-authored product catalogs and security invariants. | +| `acceptance/` | Three coequal one-Registry deployment projects. | +| `scripts/` | Product-local validation, neutrality, and fixture checks. | + +The acceptance projects are synthetic. Their identifiers, organisations, and +records are deliberately fictional and use reserved `.invalid` service names. +Tracked SQL constructs each SQLite fixture; generated `.sqlite` files are not +committed. + +## Current checks + +Run the focused product gates from the repository root: + +```bash +products/relay-v2/scripts/check-contracts.sh +products/relay-v2/scripts/check-generated.sh +products/relay-v2/scripts/test-http.sh +``` + +Together they validate the product catalogs and configs, build each SQLite +fixture in a temporary directory, run the canonical `relayctl test` journeys, +reproduce generated artifacts, enforce source neutrality, and exercise all +three deployments through the real Relay router. diff --git a/products/relay-v2/STANDARDS-ALIGNMENT.md b/products/relay-v2/STANDARDS-ALIGNMENT.md new file mode 100644 index 000000000..05e472f99 --- /dev/null +++ b/products/relay-v2/STANDARDS-ALIGNMENT.md @@ -0,0 +1,53 @@ +# Relay V2 standards alignment + +Status: Maintained directional alignment note +Last reviewed: 2026-08-10 + +Relay V2 was reviewed against the written GovStack Digital Registries draft +`3.0.0-alpha.2`, its CFR target `govstack-cfr-2.1.0`, and the GovStack API +Design Guide draft `0.1.0-draft`. These are directional design inputs. This +note makes no conformance, compatibility, certification, or completeness +claim. The obsolete Digital Registries OpenAPI is not an input. + +## Adopted direction + +| Input concept | Relay V2 treatment | +|---|---| +| One Registry with named authority and authoritative scope | Authored once in `RegistryContract`, compiled into service metadata and package provenance. | +| Consultation Retrieve | Identifier read is compiled only when the resource declares `read`. | +| Consultation List | Deterministic list is compiled only when the resource declares `list`; pagination and filters are closed. | +| Consultation Search | A named exact lookup is the only accepted search-shaped operation. It returns one governed Record or the unresolved outcome. | +| Registry semantics | Every resource and property has a stable local semantic identity; JSON-LD, JSON Schema, and SHACL artifacts are compiler outputs. | +| Capability discovery | The public and protected inventories are derived from compiled operations and their visibility. | +| API description | One full OpenAPI 3.1 document is package-only and one deterministic public subset is exposed at `/openapi.json`. | +| API error discipline | Relay uses RFC 9457 problems, stable Registry Stack codes, W3C Trace Context correlation, and value-free details. | + +## Intentional gaps + +- Exact lookup is not Record Match. Relay emits no candidates, confidence, or + explanation. +- Version 1 does not negotiate response language. Authored semantic labels + retain their language metadata. +- Registry Manifest, machine-readable GovStack alignment, DPV projection, and + a GovStack linter remain future adopter-tooling projections. +- A current SQLite source and its lifecycle policy do not prove that an + institution has never reassigned an identifier. +- Relay responses are unsigned. Portable signed minimum disclosure belongs to + Registry Evidence. + +## Rejected expansion + +Relay V2 does not infer or advertise Provisioning, Write, Notification, +Aggregate Data, Access Transparency, Identity Federation, Evidence, credential +lifecycle, registry administration, or a generic compatibility mode. Offline +`relayctl` authoring is not a Provisioning API, audit is not an Access +Transparency API, and OAuth resource-server behavior is not Identity +Federation. + +## Review trigger + +Review this note when either pinned draft changes, when a compiled capability +pattern changes, or when the public OpenAPI projection changes. The three +acceptance contracts carry the same pinned target versions; product validation +keeps those projects coequal and generated-baseline review makes output changes +visible. diff --git a/products/relay-v2/acceptance/business-registry/codelists/business-status.yaml b/products/relay-v2/acceptance/business-registry/codelists/business-status.yaml new file mode 100644 index 000000000..51369e789 --- /dev/null +++ b/products/relay-v2/acceptance/business-registry/codelists/business-status.yaml @@ -0,0 +1,4 @@ +id: business-registration-status +version: 1 +status: reviewed +values: [ACTIVE, SUSPENDED, CLOSED] diff --git a/products/relay-v2/acceptance/business-registry/codelists/jurisdictions.yaml b/products/relay-v2/acceptance/business-registry/codelists/jurisdictions.yaml new file mode 100644 index 000000000..ea1467fdb --- /dev/null +++ b/products/relay-v2/acceptance/business-registry/codelists/jurisdictions.yaml @@ -0,0 +1,4 @@ +id: synthetic-jurisdictions +version: 1 +status: reviewed +values: [EX-A, EX-B] diff --git a/products/relay-v2/acceptance/business-registry/codelists/legal-forms.yaml b/products/relay-v2/acceptance/business-registry/codelists/legal-forms.yaml new file mode 100644 index 000000000..afb68aebf --- /dev/null +++ b/products/relay-v2/acceptance/business-registry/codelists/legal-forms.yaml @@ -0,0 +1,4 @@ +id: business-legal-forms +version: 1 +status: reviewed +values: [COOPERATIVE, LIMITED_COMPANY, ASSOCIATION] diff --git a/products/relay-v2/acceptance/business-registry/codelists/record-lifecycle.yaml b/products/relay-v2/acceptance/business-registry/codelists/record-lifecycle.yaml new file mode 100644 index 000000000..e13fb85bc --- /dev/null +++ b/products/relay-v2/acceptance/business-registry/codelists/record-lifecycle.yaml @@ -0,0 +1,4 @@ +id: business-record-lifecycle +version: 1 +status: reviewed +values: [ACTIVE, SUSPENDED, RETIRED] diff --git a/products/relay-v2/acceptance/business-registry/expected-http.yaml b/products/relay-v2/acceptance/business-registry/expected-http.yaml new file mode 100644 index 000000000..6aec7958a --- /dev/null +++ b/products/relay-v2/acceptance/business-registry/expected-http.yaml @@ -0,0 +1,128 @@ +schemaVersion: relay.registrystack.org/http-journey/v1alpha1 +registry: urn:example:registry:registered-businesses +authorizations: {} +steps: + - id: registry-discovery + request: {method: GET, path: /v2} + expect: + status: 200 + capabilityPatterns: [consultation.list, consultation.retrieve] + absentCapabilityPatterns: [consultation.search, evidence, write, notification] + - id: first-page + request: + method: GET + path: /v2/resources/registered-business/records + query: {pageSize: 2} + expect: + status: 200 + itemCount: 2 + nextCursor: non-null + registryCoreRequired: true + - id: second-page + request: + method: GET + path: /v2/resources/registered-business/records + query: {cursor: "$nextCursor:first-page"} + expect: {status: 200, itemCount: 2, registryCoreRequired: true} + - id: terminal-page + request: + method: GET + path: /v2/resources/registered-business/records + query: {jurisdiction: EX-A, pageSize: 4} + expect: {status: 200, itemCount: 2, nextCursor: "null", registryCoreRequired: true} + - id: status-filter + request: + method: GET + path: /v2/resources/registered-business/records + query: {status: SUSPENDED} + expect: {status: 200, itemCount: 1, registryCoreRequired: true} + - id: jurisdiction-filter + request: + method: GET + path: /v2/resources/registered-business/records + query: {jurisdiction: EX-A} + expect: {status: 200, itemCount: 2, registryCoreRequired: true} + - id: filtered-page + request: + method: GET + path: /v2/resources/registered-business/records + query: {status: ACTIVE, jurisdiction: EX-A, fields: "registrationNumber,legalName"} + expect: + status: 200 + domainDataKeys: [registrationNumber, legalName] + registryCoreRequired: true + - id: second-field-subset + request: + method: GET + path: /v2/resources/registered-business/records + query: {fields: registrationStatus, pageSize: 2} + expect: {status: 200, itemCount: 2, registryCoreRequired: true, domainDataKeys: [registrationStatus]} + - id: identifier-read + request: {method: GET, path: /v2/resources/registered-business/records/BIZ-SYNTH-0001} + expect: + status: 200 + recordIdentifier: BIZ-SYNTH-0001 + cache: public-snapshot-revalidation + - id: identifier-read-jsonld + request: + method: GET + path: /v2/resources/registered-business/records/BIZ-SYNTH-0001 + headers: {accept: application/ld+json} + expect: + status: 200 + registryCoreRequired: true + recordsEquivalentTo: identifier-read + - id: identifier-read-revalidated + request: + method: GET + path: /v2/resources/registered-business/records/BIZ-SYNTH-0001 + headers: {if-none-match: "$etag:identifier-read"} + expect: + status: 304 + bodyEmpty: true + etagSameAs: identifier-read + - id: unknown-field + request: + method: GET + path: /v2/resources/registered-business/records + query: {fields: notGoverned} + expect: {status: 400, code: request.fields_invalid} + - id: duplicate-field + request: + method: GET + path: /v2/resources/registered-business/records + query: {fields: "legalName,legalName"} + expect: {status: 400, code: request.fields_invalid} + - id: source-column-field + request: + method: GET + path: /v2/resources/registered-business/records + query: {fields: legal_name} + expect: {status: 400, code: request.fields_invalid} + - id: malformed-field + request: + method: GET + path: /v2/resources/registered-business/records + query: {fields: ",legalName"} + expect: {status: 400, code: request.fields_invalid} + - id: unknown-filter + request: + method: GET + path: /v2/resources/registered-business/records + query: {legalName: Example} + expect: {status: 400, code: filter.unknown_field} + - id: arbitrary-sort + request: + method: GET + path: /v2/resources/registered-business/records + query: {sort: legalName} + expect: {status: 400, code: filter.unknown_field} + - id: unsupported-filter-operator + request: + method: GET + path: /v2/resources/registered-business/records + query: {"status[eq]": ACTIVE} + expect: {status: 400, code: filter.unknown_field} + - id: invalid-source-row + request: {method: GET, path: /v2/resources/registered-business/records/BIZ-SYNTH-BAD1} + expect: {status: 404, code: consultation.unresolved, equivalenceClass: unresolved} diff --git a/products/relay-v2/acceptance/business-registry/fixture.sql b/products/relay-v2/acceptance/business-registry/fixture.sql new file mode 100644 index 000000000..b49c08428 --- /dev/null +++ b/products/relay-v2/acceptance/business-registry/fixture.sql @@ -0,0 +1,30 @@ +PRAGMA foreign_keys = ON; + +CREATE TABLE source_registered_businesses ( + registration_number TEXT PRIMARY KEY NOT NULL, + record_revision TEXT NOT NULL, + lifecycle_state TEXT NOT NULL, + recorded_at TEXT NOT NULL, + legal_name TEXT NOT NULL, + registration_status TEXT NOT NULL, + legal_form TEXT NOT NULL, + jurisdiction_code TEXT NOT NULL +) STRICT; + +INSERT INTO source_registered_businesses VALUES +('BIZ-SYNTH-0001', '7', 'ACTIVE', '2026-06-01T08:00:00Z', 'Example Orchard Cooperative', 'ACTIVE', 'COOPERATIVE', 'EX-A'), +('BIZ-SYNTH-0002', '4', 'ACTIVE', '2026-06-02T08:00:00Z', 'Synthetic River Trading Ltd', 'ACTIVE', 'LIMITED_COMPANY', 'EX-B'), +('BIZ-SYNTH-0003', '9', 'SUSPENDED', '2026-06-03T08:00:00Z', 'Demonstration Workshop Association', 'SUSPENDED', 'ASSOCIATION', 'EX-A'), +('BIZ-SYNTH-0004', '2', 'RETIRED', '2026-06-04T08:00:00Z', 'Fixture Market Cooperative', 'CLOSED', 'COOPERATIVE', 'EX-B'), +('BIZ-SYNTH-BAD1', '1', 'ACTIVE', 'not-a-date-time', 'Invalid Fixture Enterprise', 'ACTIVE', 'LIMITED_COMPANY', 'EX-B'); + +CREATE VIEW relay_registered_businesses AS +SELECT registration_number, + record_revision, + lifecycle_state, + recorded_at, + legal_name, + registration_status, + legal_form, + jurisdiction_code +FROM source_registered_businesses; diff --git a/products/relay-v2/acceptance/business-registry/governance/identifier-lifecycle.yaml b/products/relay-v2/acceptance/business-registry/governance/identifier-lifecycle.yaml new file mode 100644 index 000000000..73d879d30 --- /dev/null +++ b/products/relay-v2/acceptance/business-registry/governance/identifier-lifecycle.yaml @@ -0,0 +1,7 @@ +schemaVersion: relay.registrystack.org/identifier-lifecycle/v1alpha1 +registry: urn:example:registry:registered-businesses +policyStatus: reviewed-synthetic +rules: + stableAcrossRevisions: true + retiredIdentifiersReassigned: false +evidenceFixtures: [fixture.sql] diff --git a/products/relay-v2/acceptance/business-registry/governance/legal-basis.yaml b/products/relay-v2/acceptance/business-registry/governance/legal-basis.yaml new file mode 100644 index 000000000..d2eda98cd --- /dev/null +++ b/products/relay-v2/acceptance/business-registry/governance/legal-basis.yaml @@ -0,0 +1,4 @@ +schemaVersion: relay.registrystack.org/legal-basis-reference/v1alpha1 +status: synthetic-test-only +identifier: urn:example:legal-basis:business-register-publication +statement: Fictional publication authority used only for acceptance testing. diff --git a/products/relay-v2/acceptance/business-registry/registry.yaml b/products/relay-v2/acceptance/business-registry/registry.yaml new file mode 100644 index 000000000..141ad0262 --- /dev/null +++ b/products/relay-v2/acceptance/business-registry/registry.yaml @@ -0,0 +1,131 @@ +apiVersion: relay.registrystack.org/v2alpha1 +kind: RegistryContract +metadata: + id: registered-businesses + version: 2026-08-01 + title: Synthetic registered businesses +registry: + registryIdentifier: urn:example:registry:registered-businesses + name: Synthetic registered business Registry + authority: + identifier: urn:example:institution:company-registrar + name: Example Company Registrar + operator: + identifier: urn:example:institution:digital-service-operator + name: Example Digital Service Operator + authoritativeScope: Synthetic legal business registrations for Relay acceptance testing + baseUri: https://business.example.invalid/registry/ + identifierLifecyclePolicyRef: governance/identifier-lifecycle.yaml + alignmentTargets: + - name: govstack-digital-registries + version: 3.0.0-alpha.2 + status: directional + - name: govstack-api-design-guide + version: 0.1.0-draft + status: directional +governance: + controller: urn:example:institution:company-registrar + publisher: urn:example:institution:company-registrar + auditOwner: urn:example:institution:company-registrar-audit +semantics: + localVocabulary: https://business.example.invalid/vocabulary/ + alignments: + - id: semic-business-alignment + profileRef: semantics/semic-business-alignment.yaml + digest: sha256:69c6efc233798949fb69457583733b837ce7fa36d238828e9357533383f197c3 + version: "1" + relationRequired: true +classifications: + privacy: {scheme: https://w3id.org/dpv, version: "2.3"} + institutional: {scheme: https://business.example.invalid/classification, version: "1"} + handling: {scheme: https://id.registrystack.org/vocab/handling, version: "1"} + provenanceRef: governance/legal-basis.yaml +sources: + companies: + kind: sqlite + profile: snapshot + expectedSchemaFingerprint: sha256:c978e36d7f0de71e8aa8245cdc8501ffdd760d87d9e6e0df97fd1228712361f1 +resources: + - id: registered-business + title: Registered business + description: Current synthetic public business registration. + semanticClass: local:RegisteredBusiness + source: + source: companies + view: relay_registered_businesses + classificationDefaults: {privacy: non-personal, institutional: public, handling: public, status: reviewed} + recordContext: + recordIdentifier: {sourceColumn: registration_number} + revisionIdentifier: {sourceColumn: record_revision} + lifecycleState: {sourceColumn: lifecycle_state, codelist: codelists/record-lifecycle.yaml} + recordedAt: {sourceColumn: recorded_at} + properties: + registrationNumber: + sourceColumn: registration_number + type: string + sourceRequired: true + semanticTerm: local:registrationNumber + label: Registration number + description: Stable synthetic business registration number. + legalName: + sourceColumn: legal_name + type: string + sourceRequired: true + semanticTerm: local:legalName + label: Legal name + description: Registered legal name of the synthetic organisation. + classification: {privacy: potentially-personal, institutional: public-by-law} + registrationStatus: + sourceColumn: registration_status + type: controlled-code + codelist: codelists/business-status.yaml + sourceRequired: true + semanticTerm: local:registrationStatus + label: Registration status + description: Current registration lifecycle status. + legalForm: + sourceColumn: legal_form + type: controlled-code + codelist: codelists/legal-forms.yaml + sourceRequired: true + semanticTerm: local:legalForm + label: Legal form + description: Declared legal form of the synthetic organisation. + registeredJurisdiction: + sourceColumn: jurisdiction_code + type: controlled-code + codelist: codelists/jurisdictions.yaml + sourceRequired: true + semanticTerm: local:registeredJurisdiction + label: Registered jurisdiction + description: Jurisdiction maintaining the synthetic registration. + disclosureProfiles: + public-register: + properties: [registrationNumber, legalName, registrationStatus, legalForm, registeredJurisdiction] + operations: + list: + access: public + disclosureProfile: public-register + filters: + - {name: status, property: registrationStatus, type: controlled-code} + - {name: jurisdiction, property: registeredJurisdiction, type: controlled-code} + allowUnfiltered: true + orderBy: [registrationNumber] + pagination: {defaultPageSize: 2, maximumPageSize: 4} + read: + access: public + disclosureProfile: public-register + processingDescriptions: + - id: statutory-publication + operationRefs: [list, read] + purpose: statutory-publication + recipientClass: public + legalBasisRef: governance/legal-basis.yaml + dpvProfileRef: governance/legal-basis.yaml + safeguards: [reviewed-public-view, property-minimization, deterministic-pagination, change-impact-review] +metadataVisibility: + service: public + resources: public + semantics: public + classifications: public + processing: public diff --git a/products/relay-v2/acceptance/business-registry/runtime.yaml b/products/relay-v2/acceptance/business-registry/runtime.yaml new file mode 100644 index 000000000..9bcd7745b --- /dev/null +++ b/products/relay-v2/acceptance/business-registry/runtime.yaml @@ -0,0 +1,24 @@ +apiVersion: relay.registrystack.org/v2alpha1 +kind: RelayRuntime +server: + bind: 127.0.0.1:18082 +packagePath: package +sources: + companies: + path: fixture.sqlite +authentication: + issuer: null +audit: + sink: var/audit.jsonl + integrityKeyRef: secret:env/RELAY_TEST_AUDIT_KEY +cursor: + integrityKeyRef: secret:env/RELAY_TEST_CURSOR_KEY + maximumAgeSeconds: 300 +limits: + requestTimeoutMilliseconds: 1500 + concurrentQueries: 32 +quotas: + requestsPerMinute: 10000 + burst: 1000 +shutdown: + gracePeriodMilliseconds: 1000 diff --git a/products/relay-v2/acceptance/business-registry/semantics/local-vocabulary.yaml b/products/relay-v2/acceptance/business-registry/semantics/local-vocabulary.yaml new file mode 100644 index 000000000..583c1000e --- /dev/null +++ b/products/relay-v2/acceptance/business-registry/semantics/local-vocabulary.yaml @@ -0,0 +1,12 @@ +schemaVersion: relay.registrystack.org/local-vocabulary/v1alpha1 +baseIri: https://business.example.invalid/vocabulary/ +origin: curated-local +reviewStatus: reviewed +classes: + - {id: RegisteredBusiness, label: Registered business, description: Synthetic current business registration.} +properties: + - {id: registrationNumber, label: Registration number, datatype: string, cardinality: one} + - {id: legalName, label: Legal name, datatype: string, cardinality: one} + - {id: registrationStatus, label: Registration status, datatype: controlled-code, cardinality: one} + - {id: legalForm, label: Legal form, datatype: controlled-code, cardinality: one} + - {id: registeredJurisdiction, label: Registered jurisdiction, datatype: controlled-code, cardinality: one} diff --git a/products/relay-v2/acceptance/business-registry/semantics/semic-business-alignment.yaml b/products/relay-v2/acceptance/business-registry/semantics/semic-business-alignment.yaml new file mode 100644 index 000000000..f1501153c --- /dev/null +++ b/products/relay-v2/acceptance/business-registry/semantics/semic-business-alignment.yaml @@ -0,0 +1,8 @@ +schemaVersion: relay.registrystack.org/semantic-alignment/v1alpha1 +profile: https://semiceu.github.io/Core-Business-Vocabulary/ +profileVersion: reviewed-2026-08-09 +profileDigest: sha256:4d8a80f57267b670b307bda30365a33f9bac291687011290195b2496ca39cc01 +status: illustrative-pinned-input +mappings: + - {local: local:RegisteredBusiness, external: https://data.europa.eu/m8g/LegalEntity, relation: close} + - {local: local:legalName, external: https://data.europa.eu/m8g/legalName, relation: close} diff --git a/products/relay-v2/acceptance/civil-event/codelists/civil-event-types.yaml b/products/relay-v2/acceptance/civil-event/codelists/civil-event-types.yaml new file mode 100644 index 000000000..0f1874549 --- /dev/null +++ b/products/relay-v2/acceptance/civil-event/codelists/civil-event-types.yaml @@ -0,0 +1,4 @@ +id: civil-event-types +version: 1 +status: reviewed +values: [BIRTH, DEATH, MARRIAGE] diff --git a/products/relay-v2/acceptance/civil-event/codelists/record-lifecycle.yaml b/products/relay-v2/acceptance/civil-event/codelists/record-lifecycle.yaml new file mode 100644 index 000000000..182353ead --- /dev/null +++ b/products/relay-v2/acceptance/civil-event/codelists/record-lifecycle.yaml @@ -0,0 +1,4 @@ +id: civil-event-record-lifecycle +version: 1 +status: reviewed +values: [ACTIVE, RETIRED, SEALED] diff --git a/products/relay-v2/acceptance/civil-event/codelists/registration-areas.yaml b/products/relay-v2/acceptance/civil-event/codelists/registration-areas.yaml new file mode 100644 index 000000000..956559536 --- /dev/null +++ b/products/relay-v2/acceptance/civil-event/codelists/registration-areas.yaml @@ -0,0 +1,4 @@ +id: synthetic-registration-areas +version: 1 +status: reviewed +values: [AREA-A, AREA-B] diff --git a/products/relay-v2/acceptance/civil-event/codelists/registration-status.yaml b/products/relay-v2/acceptance/civil-event/codelists/registration-status.yaml new file mode 100644 index 000000000..073f6263c --- /dev/null +++ b/products/relay-v2/acceptance/civil-event/codelists/registration-status.yaml @@ -0,0 +1,4 @@ +id: civil-event-registration-status +version: 1 +status: reviewed +values: [REGISTERED, CORRECTED, SEALED] diff --git a/products/relay-v2/acceptance/civil-event/expected-http.yaml b/products/relay-v2/acceptance/civil-event/expected-http.yaml new file mode 100644 index 000000000..50809c807 --- /dev/null +++ b/products/relay-v2/acceptance/civil-event/expected-http.yaml @@ -0,0 +1,170 @@ +schemaVersion: relay.registrystack.org/http-journey/v1alpha1 +registry: urn:example:registry:civil-events +authorizations: + civil-registrar-ex-a: + principal: synthetic-registrar-client + scopes: [registry:civil-events:read] + claims: {purpose: civil-registration-administration, jurisdiction: EX-A} + civil-verifier-ex-a: + principal: synthetic-verifier-client + scopes: [registry:civil-events:lookup] + claims: {purpose: registration-verification, jurisdiction: EX-A} + civil-verifier-wrong-purpose: + principal: synthetic-verifier-client + scopes: [registry:civil-events:lookup] + claims: {purpose: civil-registration-administration, jurisdiction: EX-A} + civil-verifier-wrong-binding: + principal: synthetic-verifier-client + scopes: [registry:civil-events:lookup] + claims: {purpose: registration-verification, jurisdiction: EX-B} +steps: + - id: registry-discovery + authorizationFixture: civil-registrar-ex-a + request: {method: GET, path: /v2} + expect: + status: 200 + capabilityPatterns: [consultation.retrieve] + absentCapabilityPatterns: [consultation.search, consultation.list, consultation.record-match, evidence] + - id: registrar-read + authorizationFixture: civil-registrar-ex-a + request: + method: GET + path: /v2/resources/civil-event/records/EVENT-SYNTH-0001 + query: {fields: "eventType,registrationStatus,registrationDate"} + expect: + status: 200 + registryCoreRequired: true + domainDataKeys: [eventType, registrationStatus, registrationDate] + - id: registrar-read-default + authorizationFixture: civil-registrar-ex-a + request: {method: GET, path: /v2/resources/civil-event/records/EVENT-SYNTH-0001} + expect: + status: 200 + registryCoreRequired: true + domainDataKeys: [eventReference, eventType, registrationStatus, registrationDate, registrationArea, certificateAvailable] + - id: registrar-read-second-subset + authorizationFixture: civil-registrar-ex-a + request: + method: GET + path: /v2/resources/civil-event/records/EVENT-SYNTH-0001 + query: {fields: "eventReference,certificateAvailable"} + expect: {status: 200, registryCoreRequired: true, domainDataKeys: [eventReference, certificateAvailable]} + - id: registrar-read-jsonld + authorizationFixture: civil-registrar-ex-a + request: + method: GET + path: /v2/resources/civil-event/records/EVENT-SYNTH-0001 + headers: {accept: application/ld+json} + query: {fields: "eventType,registrationStatus,registrationDate"} + expect: + status: 200 + registryCoreRequired: true + domainDataKeys: [eventType, registrationStatus, registrationDate] + recordsEquivalentTo: registrar-read + - id: lookup-success + authorizationFixture: civil-verifier-ex-a + request: + method: POST + path: /v2/resources/civil-event/lookups/verify-registration + query: {fields: "eventType,registrationStatus,certificateAvailable"} + body: {registrationNumber: REG-SYNTH-000001, eventType: BIRTH} + expect: + status: 200 + registryCoreRequired: true + domainDataKeys: [eventType, registrationStatus, certificateAvailable] + - id: no-list + authorizationFixture: civil-registrar-ex-a + request: {method: GET, path: /v2/resources/civil-event/records} + expect: {status: 404, routeAbsent: true} + - id: scope-separation + authorizationFixture: civil-verifier-ex-a + request: {method: GET, path: /v2/resources/civil-event/records/EVENT-SYNTH-0001} + expect: {status: 403, code: consultation.denied} + - id: reverse-scope-separation + authorizationFixture: civil-registrar-ex-a + request: + method: POST + path: /v2/resources/civil-event/lookups/verify-registration + body: {registrationNumber: REG-SYNTH-000001, eventType: BIRTH} + expect: {status: 403, code: consultation.denied} + - id: wrong-purpose + authorizationFixture: civil-verifier-wrong-purpose + request: + method: POST + path: /v2/resources/civil-event/lookups/verify-registration + body: {registrationNumber: REG-SYNTH-000001, eventType: BIRTH} + expect: {status: 403, code: consultation.denied} + - id: wrong-binding + authorizationFixture: civil-verifier-wrong-binding + request: + method: POST + path: /v2/resources/civil-event/lookups/verify-registration + body: {registrationNumber: REG-SYNTH-000001, eventType: BIRTH} + expect: {status: 404, code: consultation.unresolved, equivalenceClass: unresolved} + - id: unknown-field + authorizationFixture: civil-verifier-ex-a + request: + method: POST + path: /v2/resources/civil-event/lookups/verify-registration + query: {fields: notGoverned} + body: {registrationNumber: REG-SYNTH-000001, eventType: BIRTH} + expect: {status: 400, code: request.fields_invalid} + - id: duplicate-field + authorizationFixture: civil-verifier-ex-a + request: + method: POST + path: /v2/resources/civil-event/lookups/verify-registration + query: {fields: "eventType,eventType"} + body: {registrationNumber: REG-SYNTH-000001, eventType: BIRTH} + expect: {status: 400, code: request.fields_invalid} + - id: source-column-field + authorizationFixture: civil-verifier-ex-a + request: + method: POST + path: /v2/resources/civil-event/lookups/verify-registration + query: {fields: event_type} + body: {registrationNumber: REG-SYNTH-000001, eventType: BIRTH} + expect: {status: 400, code: request.fields_invalid} + - id: malformed-field + authorizationFixture: civil-verifier-ex-a + request: + method: POST + path: /v2/resources/civil-event/lookups/verify-registration + query: {fields: ",eventType"} + body: {registrationNumber: REG-SYNTH-000001, eventType: BIRTH} + expect: {status: 400, code: request.fields_invalid} + - id: jurisdiction-hidden + authorizationFixture: civil-verifier-ex-a + request: + method: POST + path: /v2/resources/civil-event/lookups/verify-registration + body: {registrationNumber: REG-SYNTH-000002, eventType: DEATH} + expect: {status: 404, code: consultation.unresolved, equivalenceClass: unresolved} + - id: no-match + authorizationFixture: civil-verifier-ex-a + request: + method: POST + path: /v2/resources/civil-event/lookups/verify-registration + body: {registrationNumber: REG-SYNTH-NONE01, eventType: BIRTH} + expect: {status: 404, code: consultation.unresolved, equivalenceClass: unresolved} + - id: ambiguous + authorizationFixture: civil-verifier-ex-a + request: + method: POST + path: /v2/resources/civil-event/lookups/verify-registration + body: {registrationNumber: REG-SYNTH-AMBIG01, eventType: BIRTH} + expect: {status: 404, code: consultation.unresolved, equivalenceClass: unresolved} + - id: invalid-row + authorizationFixture: civil-verifier-ex-a + request: + method: POST + path: /v2/resources/civil-event/lookups/verify-registration + body: {registrationNumber: REG-SYNTH-INVALID1, eventType: BIRTH} + expect: {status: 404, code: consultation.unresolved, equivalenceClass: unresolved} + - id: quota-exhausted + authorizationFixture: civil-verifier-ex-a + request: + method: POST + path: /v2/resources/civil-event/lookups/verify-registration + body: {registrationNumber: REG-SYNTH-000001, eventType: BIRTH} + expect: {status: 429, code: consultation.rate_limited} diff --git a/products/relay-v2/acceptance/civil-event/fixture.sql b/products/relay-v2/acceptance/civil-event/fixture.sql new file mode 100644 index 000000000..dc64a56a8 --- /dev/null +++ b/products/relay-v2/acceptance/civil-event/fixture.sql @@ -0,0 +1,36 @@ +PRAGMA foreign_keys = ON; + +CREATE TABLE source_civil_events ( + event_reference TEXT PRIMARY KEY NOT NULL, + record_revision TEXT NOT NULL, + lifecycle_state TEXT NOT NULL, + recorded_at TEXT NOT NULL, + event_type TEXT NOT NULL, + registration_status TEXT NOT NULL, + registration_date TEXT NOT NULL, + registration_area_code TEXT NOT NULL, + certificate_available INTEGER NOT NULL CHECK (certificate_available IN (0, 1)), + jurisdiction_code TEXT NOT NULL, + registration_number TEXT NOT NULL +) STRICT; + +INSERT INTO source_civil_events VALUES +('EVENT-SYNTH-0001', '5', 'ACTIVE', '2026-05-01T10:00:00Z', 'BIRTH', 'REGISTERED', '2026-04-30', 'AREA-A', 1, 'EX-A', 'REG-SYNTH-000001'), +('EVENT-SYNTH-0002', '2', 'ACTIVE', '2026-05-02T10:00:00Z', 'DEATH', 'REGISTERED', '2026-05-01', 'AREA-B', 1, 'EX-B', 'REG-SYNTH-000002'), +('EVENT-SYNTH-0101', '1', 'ACTIVE', '2026-05-03T10:00:00Z', 'BIRTH', 'REGISTERED', '2026-05-02', 'AREA-A', 1, 'EX-A', 'REG-SYNTH-AMBIG01'), +('EVENT-SYNTH-0102', '1', 'ACTIVE', '2026-05-03T10:05:00Z', 'BIRTH', 'REGISTERED', '2026-05-02', 'AREA-A', 0, 'EX-A', 'REG-SYNTH-AMBIG01'), +('EVENT-SYNTH-BAD1', '1', 'NOT-A-LIFECYCLE', '2026-05-04T10:00:00Z', 'BIRTH', 'REGISTERED', '2026-05-03', 'AREA-A', 1, 'EX-A', 'REG-SYNTH-INVALID1'); + +CREATE VIEW relay_civil_events AS +SELECT event_reference, + record_revision, + lifecycle_state, + recorded_at, + event_type, + registration_status, + registration_date, + registration_area_code, + certificate_available, + jurisdiction_code, + registration_number +FROM source_civil_events; diff --git a/products/relay-v2/acceptance/civil-event/governance/identifier-lifecycle.yaml b/products/relay-v2/acceptance/civil-event/governance/identifier-lifecycle.yaml new file mode 100644 index 000000000..66ddcb5c5 --- /dev/null +++ b/products/relay-v2/acceptance/civil-event/governance/identifier-lifecycle.yaml @@ -0,0 +1,7 @@ +schemaVersion: relay.registrystack.org/identifier-lifecycle/v1alpha1 +registry: urn:example:registry:civil-events +policyStatus: reviewed-synthetic +rules: + stableAcrossRevisions: true + retiredIdentifiersReassigned: false +evidenceFixtures: [fixture.sql] diff --git a/products/relay-v2/acceptance/civil-event/governance/legal-basis.yaml b/products/relay-v2/acceptance/civil-event/governance/legal-basis.yaml new file mode 100644 index 000000000..2cfb6cd19 --- /dev/null +++ b/products/relay-v2/acceptance/civil-event/governance/legal-basis.yaml @@ -0,0 +1,4 @@ +schemaVersion: relay.registrystack.org/legal-basis-reference/v1alpha1 +status: synthetic-test-only +identifier: urn:example:legal-basis:civil-event-consultation +statement: Fictional registration authority used only for acceptance testing. diff --git a/products/relay-v2/acceptance/civil-event/registry.yaml b/products/relay-v2/acceptance/civil-event/registry.yaml new file mode 100644 index 000000000..1e58b8153 --- /dev/null +++ b/products/relay-v2/acceptance/civil-event/registry.yaml @@ -0,0 +1,166 @@ +apiVersion: relay.registrystack.org/v2alpha1 +kind: RegistryContract +metadata: + id: civil-events + version: 2026-08-01 + title: Synthetic civil-event registrations +registry: + registryIdentifier: urn:example:registry:civil-events + name: Synthetic civil-event Registry + authority: + identifier: urn:example:institution:civil-registration-authority + name: Example Civil Registration Authority + operator: + identifier: urn:example:institution:digital-service-operator + name: Example Digital Service Operator + authoritativeScope: Synthetic civil-event registrations for Relay acceptance testing + baseUri: https://civil-registry.example.invalid/registry/ + identifierLifecyclePolicyRef: governance/identifier-lifecycle.yaml + alignmentTargets: + - name: govstack-digital-registries + version: 3.0.0-alpha.2 + status: directional + - name: govstack-api-design-guide + version: 0.1.0-draft + status: directional +governance: + controller: urn:example:institution:civil-registration-authority + publisher: urn:example:institution:civil-registration-authority + auditOwner: urn:example:institution:civil-registration-inspectorate +semantics: + localVocabulary: https://civil-registry.example.invalid/vocabulary/ + alignments: + - id: publicschema-event-alignment + profileRef: semantics/publicschema-event-alignment.yaml + digest: sha256:c89af1aae44c66ce3ef8e6a1e6e6d9b063c50559d288b7ed290dff74422ea9d9 + version: "1" + relationRequired: true +classifications: + privacy: {scheme: https://w3id.org/dpv, version: "2.3"} + institutional: {scheme: https://civil-registry.example.invalid/classification, version: "1"} + handling: {scheme: https://id.registrystack.org/vocab/handling, version: "1"} + provenanceRef: governance/legal-basis.yaml +sources: + events: + kind: sqlite + profile: live-read-only + expectedSchemaFingerprint: sha256:7f770d64cb19ec54caca2aa56378b13a43cd5edc206ff44b5fecc99ee9e63759 +resources: + - id: civil-event + title: Civil-event registration + description: Current synthetic civil-event registration. + semanticClass: local:CivilEventRegistration + source: + source: events + view: relay_civil_events + classificationDefaults: {privacy: personal, institutional: restricted, handling: restricted, status: reviewed} + sourceColumnClassifications: + jurisdiction_code: {privacy: personal-context} + registration_number: {privacy: identifying} + recordContext: + recordIdentifier: {sourceColumn: event_reference} + revisionIdentifier: {sourceColumn: record_revision} + lifecycleState: {sourceColumn: lifecycle_state, codelist: codelists/record-lifecycle.yaml} + recordedAt: {sourceColumn: recorded_at} + properties: + eventReference: + sourceColumn: event_reference + type: string + sourceRequired: true + semanticTerm: local:eventReference + label: Event reference + description: Stable synthetic event Record identifier. + classification: {privacy: identifying} + eventType: + sourceColumn: event_type + type: controlled-code + codelist: codelists/civil-event-types.yaml + sourceRequired: true + semanticTerm: local:eventType + label: Event type + description: Type of synthetic civil event. + classification: {privacy: sensitive-personal} + registrationStatus: + sourceColumn: registration_status + type: controlled-code + codelist: codelists/registration-status.yaml + sourceRequired: true + semanticTerm: local:registrationStatus + label: Registration status + description: Current synthetic registration status. + registrationDate: + sourceColumn: registration_date + type: date + sourceRequired: true + semanticTerm: local:registrationDate + label: Registration date + description: Date the synthetic event was registered. + registrationArea: + sourceColumn: registration_area_code + type: controlled-code + codelist: codelists/registration-areas.yaml + sourceRequired: true + semanticTerm: local:registrationArea + label: Registration area + description: Administrative area maintaining the synthetic Record. + classification: {privacy: personal-context} + certificateAvailable: + sourceColumn: certificate_available + type: boolean + sourceRequired: true + semanticTerm: local:certificateAvailable + label: Certificate available + description: Whether a certificate can be requested for the synthetic event. + disclosureProfiles: + registrar-record: + properties: [eventReference, eventType, registrationStatus, registrationDate, registrationArea, certificateAvailable] + verification-result: + properties: [eventReference, eventType, registrationStatus, certificateAvailable] + operations: + read: + access: + scope: registry:civil-events:read + purpose: + claim: purpose + allowed: [civil-registration-administration] + authorityRowBinding: + claim: jurisdiction + sourceColumn: jurisdiction_code + disclosureProfile: registrar-record + lookups: + - id: verify-registration + access: + scope: registry:civil-events:lookup + purpose: + claim: purpose + allowed: [registration-verification] + authorityRowBinding: + claim: jurisdiction + sourceColumn: jurisdiction_code + requestBody: + maximumBytes: 384 + selectors: + registrationNumber: {sourceColumn: registration_number, type: string, minimumBytes: 12, maximumBytes: 96} + eventType: {sourceColumn: event_type, type: controlled-code, codelist: codelists/civil-event-types.yaml} + disclosureProfile: verification-result + processingDescriptions: + - id: registrar-administration + operationRefs: [read] + purpose: civil-registration-administration + recipientClass: authorized-registrar + legalBasisRef: governance/legal-basis.yaml + dpvProfileRef: governance/legal-basis.yaml + safeguards: [no-collection-list, operation-scopes, principal-row-binding, property-minimization, value-free-audit] + - id: registration-verification + operationRefs: [lookup:verify-registration] + purpose: registration-verification + recipientClass: authorized-verifier + legalBasisRef: governance/legal-basis.yaml + dpvProfileRef: governance/legal-basis.yaml + safeguards: [no-collection-list, operation-scopes, principal-row-binding, minimum-disclosure-profile, value-free-audit] +metadataVisibility: + service: public + resources: operation-bound + semantics: operation-bound + classifications: operator-only + processing: operation-bound diff --git a/products/relay-v2/acceptance/civil-event/runtime.yaml b/products/relay-v2/acceptance/civil-event/runtime.yaml new file mode 100644 index 000000000..1ff644ef7 --- /dev/null +++ b/products/relay-v2/acceptance/civil-event/runtime.yaml @@ -0,0 +1,24 @@ +apiVersion: relay.registrystack.org/v2alpha1 +kind: RelayRuntime +server: + bind: 127.0.0.1:18083 +packagePath: package +sources: + events: + path: fixture.sqlite +authentication: + issuer: + id: registry-mint + discoveryUrl: https://mint.example.invalid/.well-known/openid-configuration + audience: relay-civil-events + tokenTypes: [at+jwt] + algorithms: [ES256] +audit: + sink: var/audit.jsonl + integrityKeyRef: secret:env/RELAY_TEST_AUDIT_KEY +limits: + requestTimeoutMilliseconds: 1500 + concurrentQueries: 16 +quotas: + requestsPerMinute: 1 + burst: 6 diff --git a/products/relay-v2/acceptance/civil-event/semantics/local-vocabulary.yaml b/products/relay-v2/acceptance/civil-event/semantics/local-vocabulary.yaml new file mode 100644 index 000000000..56c46f6cf --- /dev/null +++ b/products/relay-v2/acceptance/civil-event/semantics/local-vocabulary.yaml @@ -0,0 +1,13 @@ +schemaVersion: relay.registrystack.org/local-vocabulary/v1alpha1 +baseIri: https://civil-registry.example.invalid/vocabulary/ +origin: curated-local +reviewStatus: reviewed +classes: + - {id: CivilEventRegistration, label: Civil-event registration, description: Synthetic current civil-event registration.} +properties: + - {id: eventReference, label: Event reference, datatype: string, cardinality: one} + - {id: eventType, label: Event type, datatype: controlled-code, cardinality: one} + - {id: registrationStatus, label: Registration status, datatype: controlled-code, cardinality: one} + - {id: registrationDate, label: Registration date, datatype: date, cardinality: one} + - {id: registrationArea, label: Registration area, datatype: controlled-code, cardinality: one} + - {id: certificateAvailable, label: Certificate available, datatype: boolean, cardinality: one} diff --git a/products/relay-v2/acceptance/civil-event/semantics/publicschema-event-alignment.yaml b/products/relay-v2/acceptance/civil-event/semantics/publicschema-event-alignment.yaml new file mode 100644 index 000000000..4bbd7ec47 --- /dev/null +++ b/products/relay-v2/acceptance/civil-event/semantics/publicschema-event-alignment.yaml @@ -0,0 +1,7 @@ +schemaVersion: relay.registrystack.org/semantic-alignment/v1alpha1 +profile: https://publicschema.org/ +profileVersion: reviewed-2026-08-09 +status: illustrative-pinned-input +mappings: + - {local: local:CivilEventRegistration, external: https://publicschema.org/CivilRegistration, relation: related} + - {local: local:eventType, external: https://publicschema.org/eventType, relation: related} diff --git a/products/relay-v2/acceptance/social-assistance/codelists/enrolment-status.yaml b/products/relay-v2/acceptance/social-assistance/codelists/enrolment-status.yaml new file mode 100644 index 000000000..16944a60c --- /dev/null +++ b/products/relay-v2/acceptance/social-assistance/codelists/enrolment-status.yaml @@ -0,0 +1,4 @@ +id: social-enrolment-status +version: 1 +status: reviewed +values: [ELIGIBLE, SUSPENDED, CLOSED] diff --git a/products/relay-v2/acceptance/social-assistance/codelists/programmes.yaml b/products/relay-v2/acceptance/social-assistance/codelists/programmes.yaml new file mode 100644 index 000000000..56dc4176c --- /dev/null +++ b/products/relay-v2/acceptance/social-assistance/codelists/programmes.yaml @@ -0,0 +1,4 @@ +id: social-programmes +version: 1 +status: reviewed +values: [PROGRAMME-A, PROGRAMME-B] diff --git a/products/relay-v2/acceptance/social-assistance/codelists/record-lifecycle.yaml b/products/relay-v2/acceptance/social-assistance/codelists/record-lifecycle.yaml new file mode 100644 index 000000000..40b9d9f56 --- /dev/null +++ b/products/relay-v2/acceptance/social-assistance/codelists/record-lifecycle.yaml @@ -0,0 +1,4 @@ +id: social-record-lifecycle +version: 1 +status: reviewed +values: [ACTIVE, SUSPENDED, RETIRED] diff --git a/products/relay-v2/acceptance/social-assistance/expected-http.yaml b/products/relay-v2/acceptance/social-assistance/expected-http.yaml new file mode 100644 index 000000000..05ce1c87b --- /dev/null +++ b/products/relay-v2/acceptance/social-assistance/expected-http.yaml @@ -0,0 +1,176 @@ +schemaVersion: relay.registrystack.org/http-journey/v1alpha1 +registry: urn:example:registry:social-assistance-enrolments +authorizations: + social-lookup-area-a: + principal: synthetic-social-client + scopes: [registry:social-assistance:lookup] + claims: {purpose: benefit-delivery, service_area: AREA-A} + social-lookup-wrong-purpose: + principal: synthetic-social-client + scopes: [registry:social-assistance:lookup] + claims: {purpose: unpermitted-purpose, service_area: AREA-A} + social-lookup-missing-purpose: + principal: synthetic-social-client + scopes: [registry:social-assistance:lookup] + claims: {service_area: AREA-A} + social-lookup-missing-binding: + principal: synthetic-social-client + scopes: [registry:social-assistance:lookup] + claims: {purpose: benefit-delivery} + social-lookup-wrong-binding: + principal: synthetic-social-client + scopes: [registry:social-assistance:lookup] + claims: {purpose: benefit-delivery, service_area: AREA-B} +steps: + - id: readiness + request: {method: GET, path: /ready} + expect: {status: 200} + - id: lookup-success + authorizationFixture: social-lookup-area-a + request: + method: POST + path: /v2/resources/assistance-enrolment/lookups/by-case-and-person + query: {fields: "enrolmentStatus,validThrough"} + body: {caseReference: CASE-SYNTH-0001, personReference: PERSON-SYNTH-0001} + expect: + status: 200 + registryCoreRequired: true + domainDataKeys: [enrolmentStatus, validThrough] + absentEverywhere: [caseReference, personReference, service_area_code] + - id: lookup-default + authorizationFixture: social-lookup-area-a + request: + method: POST + path: /v2/resources/assistance-enrolment/lookups/by-case-and-person + body: {caseReference: CASE-SYNTH-0001, personReference: PERSON-SYNTH-0001} + expect: + status: 200 + registryCoreRequired: true + domainDataKeys: [enrolmentReference, programmeCode, enrolmentStatus, validThrough] + - id: lookup-second-subset + authorizationFixture: social-lookup-area-a + request: + method: POST + path: /v2/resources/assistance-enrolment/lookups/by-case-and-person + query: {fields: programmeCode} + body: {caseReference: CASE-SYNTH-0001, personReference: PERSON-SYNTH-0001} + expect: {status: 200, registryCoreRequired: true, domainDataKeys: [programmeCode]} + - id: lookup-jsonld + authorizationFixture: social-lookup-area-a + request: + method: POST + path: /v2/resources/assistance-enrolment/lookups/by-case-and-person + headers: {accept: application/ld+json} + query: {fields: "enrolmentStatus,validThrough"} + body: {caseReference: CASE-SYNTH-0001, personReference: PERSON-SYNTH-0001} + expect: + status: 200 + registryCoreRequired: true + domainDataKeys: [enrolmentStatus, validThrough] + recordsEquivalentTo: lookup-success + - id: route-confinement + authorizationFixture: social-lookup-area-a + request: {method: GET, path: /v2/resources/assistance-enrolment/records} + expect: {status: 404, routeAbsent: true} + - id: wrong-purpose + authorizationFixture: social-lookup-wrong-purpose + request: + method: POST + path: /v2/resources/assistance-enrolment/lookups/by-case-and-person + body: {caseReference: CASE-SYNTH-0001, personReference: PERSON-SYNTH-0001} + expect: {status: 403, code: consultation.denied} + - id: missing-purpose + authorizationFixture: social-lookup-missing-purpose + request: + method: POST + path: /v2/resources/assistance-enrolment/lookups/by-case-and-person + body: {caseReference: CASE-SYNTH-0001, personReference: PERSON-SYNTH-0001} + expect: {status: 403, code: consultation.denied} + - id: missing-binding + authorizationFixture: social-lookup-missing-binding + request: + method: POST + path: /v2/resources/assistance-enrolment/lookups/by-case-and-person + body: {caseReference: CASE-SYNTH-0001, personReference: PERSON-SYNTH-0001} + expect: {status: 403, code: consultation.denied} + - id: wrong-binding + authorizationFixture: social-lookup-wrong-binding + request: + method: POST + path: /v2/resources/assistance-enrolment/lookups/by-case-and-person + body: {caseReference: CASE-SYNTH-0001, personReference: PERSON-SYNTH-0001} + expect: {status: 404, code: consultation.unresolved, equivalenceClass: unresolved} + - id: unknown-field + authorizationFixture: social-lookup-area-a + request: + method: POST + path: /v2/resources/assistance-enrolment/lookups/by-case-and-person + query: {fields: notGoverned} + body: {caseReference: CASE-SYNTH-0001, personReference: PERSON-SYNTH-0001} + expect: {status: 400, code: request.fields_invalid} + - id: duplicate-field + authorizationFixture: social-lookup-area-a + request: + method: POST + path: /v2/resources/assistance-enrolment/lookups/by-case-and-person + query: {fields: "programmeCode,programmeCode"} + body: {caseReference: CASE-SYNTH-0001, personReference: PERSON-SYNTH-0001} + expect: {status: 400, code: request.fields_invalid} + - id: source-column-field + authorizationFixture: social-lookup-area-a + request: + method: POST + path: /v2/resources/assistance-enrolment/lookups/by-case-and-person + query: {fields: programme_code} + body: {caseReference: CASE-SYNTH-0001, personReference: PERSON-SYNTH-0001} + expect: {status: 400, code: request.fields_invalid} + - id: malformed-field + authorizationFixture: social-lookup-area-a + request: + method: POST + path: /v2/resources/assistance-enrolment/lookups/by-case-and-person + query: {fields: ",programmeCode"} + body: {caseReference: CASE-SYNTH-0001, personReference: PERSON-SYNTH-0001} + expect: {status: 400, code: request.fields_invalid} + - id: no-match + authorizationFixture: social-lookup-area-a + request: + method: POST + path: /v2/resources/assistance-enrolment/lookups/by-case-and-person + body: {caseReference: CASE-SYNTH-NONE, personReference: PERSON-SYNTH-NONE} + expect: {status: 404, code: consultation.unresolved, equivalenceClass: unresolved} + - id: ambiguous + authorizationFixture: social-lookup-area-a + request: + method: POST + path: /v2/resources/assistance-enrolment/lookups/by-case-and-person + body: {caseReference: CASE-SYNTH-AMBIG, personReference: PERSON-SYNTH-AMBIG} + expect: {status: 404, code: consultation.unresolved, equivalenceClass: unresolved} + - id: row-hidden + authorizationFixture: social-lookup-area-a + request: + method: POST + path: /v2/resources/assistance-enrolment/lookups/by-case-and-person + body: {caseReference: CASE-SYNTH-0002, personReference: PERSON-SYNTH-0002} + expect: {status: 404, code: consultation.unresolved, equivalenceClass: unresolved} + - id: invalid-row + authorizationFixture: social-lookup-area-a + request: + method: POST + path: /v2/resources/assistance-enrolment/lookups/by-case-and-person + body: {caseReference: CASE-SYNTH-BAD1, personReference: PERSON-SYNTH-BAD1} + expect: {status: 404, code: consultation.unresolved, equivalenceClass: unresolved} + - id: excessive-row + authorizationFixture: social-lookup-area-a + request: + method: POST + path: /v2/resources/assistance-enrolment/lookups/by-case-and-person + body: {caseReference: CASE-SYNTH-BAD2, personReference: PERSON-SYNTH-BAD2} + expect: {status: 404, code: consultation.unresolved, equivalenceClass: unresolved} + - id: quota-exhausted + authorizationFixture: social-lookup-area-a + request: + method: POST + path: /v2/resources/assistance-enrolment/lookups/by-case-and-person + body: {caseReference: CASE-SYNTH-0001, personReference: PERSON-SYNTH-0001} + expect: {status: 429, code: consultation.rate_limited} diff --git a/products/relay-v2/acceptance/social-assistance/fixture.sql b/products/relay-v2/acceptance/social-assistance/fixture.sql new file mode 100644 index 000000000..94b550aa9 --- /dev/null +++ b/products/relay-v2/acceptance/social-assistance/fixture.sql @@ -0,0 +1,35 @@ +PRAGMA foreign_keys = ON; + +CREATE TABLE source_assistance_enrolments ( + enrolment_reference TEXT PRIMARY KEY NOT NULL, + record_revision TEXT NOT NULL, + lifecycle_state TEXT NOT NULL, + recorded_at TEXT NOT NULL, + programme_code TEXT NOT NULL, + enrolment_status TEXT NOT NULL, + valid_through TEXT, + service_area_code TEXT NOT NULL, + case_reference TEXT NOT NULL, + person_reference TEXT NOT NULL +) STRICT; + +INSERT INTO source_assistance_enrolments VALUES +('ENROL-SYNTH-0001', '3', 'ACTIVE', '2026-07-01T09:00:00Z', 'PROGRAMME-A', 'ELIGIBLE', '2026-12-31', 'AREA-A', 'CASE-SYNTH-0001', 'PERSON-SYNTH-0001'), +('ENROL-SYNTH-0002', '2', 'SUSPENDED', '2026-07-02T09:00:00Z', 'PROGRAMME-B', 'SUSPENDED', NULL, 'AREA-B', 'CASE-SYNTH-0002', 'PERSON-SYNTH-0002'), +('ENROL-SYNTH-0101', '1', 'ACTIVE', '2026-07-03T09:00:00Z', 'PROGRAMME-A', 'ELIGIBLE', '2026-12-31', 'AREA-A', 'CASE-SYNTH-AMBIG', 'PERSON-SYNTH-AMBIG'), +('ENROL-SYNTH-0102', '1', 'ACTIVE', '2026-07-03T09:05:00Z', 'PROGRAMME-B', 'ELIGIBLE', '2026-12-31', 'AREA-A', 'CASE-SYNTH-AMBIG', 'PERSON-SYNTH-AMBIG'), +('ENROL-SYNTH-BAD1', '', 'ACTIVE', '2026-07-04T09:00:00Z', 'PROGRAMME-A', 'ELIGIBLE', '2026-12-31', 'AREA-A', 'CASE-SYNTH-BAD1', 'PERSON-SYNTH-BAD1'), +('XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', '1', 'ACTIVE', '2026-07-05T09:00:00Z', 'PROGRAMME-A', 'ELIGIBLE', '2026-12-31', 'AREA-A', 'CASE-SYNTH-BAD2', 'PERSON-SYNTH-BAD2'); + +CREATE VIEW relay_assistance_enrolments AS +SELECT enrolment_reference, + record_revision, + lifecycle_state, + recorded_at, + programme_code, + enrolment_status, + valid_through, + service_area_code, + case_reference, + person_reference +FROM source_assistance_enrolments; diff --git a/products/relay-v2/acceptance/social-assistance/governance/identifier-lifecycle.yaml b/products/relay-v2/acceptance/social-assistance/governance/identifier-lifecycle.yaml new file mode 100644 index 000000000..235079c62 --- /dev/null +++ b/products/relay-v2/acceptance/social-assistance/governance/identifier-lifecycle.yaml @@ -0,0 +1,7 @@ +schemaVersion: relay.registrystack.org/identifier-lifecycle/v1alpha1 +registry: urn:example:registry:social-assistance-enrolments +policyStatus: reviewed-synthetic +rules: + stableAcrossRevisions: true + retiredIdentifiersReassigned: false +evidenceFixtures: [fixture.sql] diff --git a/products/relay-v2/acceptance/social-assistance/governance/legal-basis.yaml b/products/relay-v2/acceptance/social-assistance/governance/legal-basis.yaml new file mode 100644 index 000000000..4c9a1a0ab --- /dev/null +++ b/products/relay-v2/acceptance/social-assistance/governance/legal-basis.yaml @@ -0,0 +1,4 @@ +schemaVersion: relay.registrystack.org/legal-basis-reference/v1alpha1 +status: synthetic-test-only +identifier: urn:example:legal-basis:social-assistance-consultation +statement: Fictional authority used only to exercise required governance bindings. diff --git a/products/relay-v2/acceptance/social-assistance/registry.yaml b/products/relay-v2/acceptance/social-assistance/registry.yaml new file mode 100644 index 000000000..4cca449d6 --- /dev/null +++ b/products/relay-v2/acceptance/social-assistance/registry.yaml @@ -0,0 +1,127 @@ +apiVersion: relay.registrystack.org/v2alpha1 +kind: RegistryContract +metadata: + id: social-assistance-enrolments + version: 2026-08-01 + title: Synthetic social assistance enrolment consultations +registry: + registryIdentifier: urn:example:registry:social-assistance-enrolments + name: Synthetic social assistance enrolment Registry + authority: + identifier: urn:example:institution:social-protection-authority + name: Example Social Protection Authority + operator: + identifier: urn:example:institution:digital-service-operator + name: Example Digital Service Operator + authoritativeScope: Synthetic social assistance enrolment decisions for Relay acceptance testing + baseUri: https://social-registry.example.invalid/registry/ + identifierLifecyclePolicyRef: governance/identifier-lifecycle.yaml + alignmentTargets: + - name: govstack-digital-registries + version: 3.0.0-alpha.2 + status: directional + - name: govstack-api-design-guide + version: 0.1.0-draft + status: directional +governance: + controller: urn:example:institution:social-protection-authority + publisher: urn:example:institution:social-registry-office + auditOwner: urn:example:institution:internal-audit +semantics: + localVocabulary: https://social-registry.example.invalid/vocabulary/ + alignments: [] +classifications: + privacy: {scheme: https://w3id.org/dpv, version: "2.3"} + institutional: {scheme: https://social-registry.example.invalid/classification, version: "1"} + handling: {scheme: https://id.registrystack.org/vocab/handling, version: "1"} + provenanceRef: governance/legal-basis.yaml +sources: + assistance: + kind: sqlite + profile: live-read-only + expectedSchemaFingerprint: sha256:256b74415d1da84efee8fecd3c59e4cebbbc99f43d77e3aa8b75e17d3b3a71d1 +resources: + - id: assistance-enrolment + title: Assistance enrolment + description: Current synthetic assistance enrolment status. + semanticClass: local:AssistanceEnrolment + source: + source: assistance + view: relay_assistance_enrolments + classificationDefaults: {privacy: sensitive-personal, institutional: restricted, handling: restricted, status: reviewed} + sourceColumnClassifications: + case_reference: {privacy: identifying} + person_reference: {privacy: identifying} + service_area_code: {privacy: personal-context} + recordContext: + recordIdentifier: {sourceColumn: enrolment_reference} + revisionIdentifier: {sourceColumn: record_revision} + lifecycleState: {sourceColumn: lifecycle_state, codelist: codelists/record-lifecycle.yaml} + recordedAt: {sourceColumn: recorded_at} + properties: + enrolmentReference: + sourceColumn: enrolment_reference + type: string + sourceRequired: true + semanticTerm: local:enrolmentReference + label: Enrolment reference + description: Stable synthetic enrolment reference. + classification: {privacy: identifying} + programmeCode: + sourceColumn: programme_code + type: controlled-code + codelist: codelists/programmes.yaml + sourceRequired: true + semanticTerm: local:programme + label: Programme + description: Programme under which the synthetic enrolment is maintained. + enrolmentStatus: + sourceColumn: enrolment_status + type: controlled-code + codelist: codelists/enrolment-status.yaml + sourceRequired: true + semanticTerm: local:enrolmentStatus + label: Enrolment status + description: Current status of the synthetic enrolment. + validThrough: + sourceColumn: valid_through + type: date + sourceRequired: false + semanticTerm: local:validThrough + label: Valid through + description: Last date on which the status is valid when bounded. + classification: {privacy: personal} + disclosureProfiles: + consultation: + properties: [enrolmentReference, programmeCode, enrolmentStatus, validThrough] + operations: + lookups: + - id: by-case-and-person + access: + scope: registry:social-assistance:lookup + purpose: + claim: purpose + allowed: [benefit-delivery] + authorityRowBinding: + claim: service_area + sourceColumn: service_area_code + requestBody: + maximumBytes: 512 + selectors: + caseReference: {sourceColumn: case_reference, type: string, minimumBytes: 8, maximumBytes: 96} + personReference: {sourceColumn: person_reference, type: string, minimumBytes: 8, maximumBytes: 96} + disclosureProfile: consultation + processingDescriptions: + - id: benefit-delivery-consultation + operationRefs: [lookup:by-case-and-person] + purpose: benefit-delivery + recipientClass: authorized-service-officer + legalBasisRef: governance/legal-basis.yaml + dpvProfileRef: governance/legal-basis.yaml + safeguards: [exact-lookup, principal-row-binding, property-minimization, value-free-audit] +metadataVisibility: + service: public + resources: operation-bound + semantics: operation-bound + classifications: operator-only + processing: operation-bound diff --git a/products/relay-v2/acceptance/social-assistance/runtime.yaml b/products/relay-v2/acceptance/social-assistance/runtime.yaml new file mode 100644 index 000000000..788a7a70e --- /dev/null +++ b/products/relay-v2/acceptance/social-assistance/runtime.yaml @@ -0,0 +1,24 @@ +apiVersion: relay.registrystack.org/v2alpha1 +kind: RelayRuntime +server: + bind: 127.0.0.1:18081 +packagePath: package +sources: + assistance: + path: fixture.sqlite +authentication: + issuer: + id: synthetic-external-issuer + discoveryUrl: https://identity.example.invalid/.well-known/openid-configuration + audience: relay-social-assistance + tokenTypes: [at+jwt] + algorithms: [ES256] +audit: + sink: var/audit.jsonl + integrityKeyRef: secret:env/RELAY_TEST_AUDIT_KEY +limits: + requestTimeoutMilliseconds: 1500 + concurrentQueries: 16 +quotas: + requestsPerMinute: 1 + burst: 10 diff --git a/products/relay-v2/acceptance/social-assistance/semantics/local-vocabulary.yaml b/products/relay-v2/acceptance/social-assistance/semantics/local-vocabulary.yaml new file mode 100644 index 000000000..2f70a01f5 --- /dev/null +++ b/products/relay-v2/acceptance/social-assistance/semantics/local-vocabulary.yaml @@ -0,0 +1,14 @@ +schemaVersion: relay.registrystack.org/local-vocabulary/v1alpha1 +baseIri: https://social-registry.example.invalid/vocabulary/ +origin: generated-local +reviewStatus: reviewed +externalMappings: [] +classes: + - id: AssistanceEnrolment + label: Assistance enrolment + description: Synthetic current enrolment maintained by the example Registry. +properties: + - {id: enrolmentReference, label: Enrolment reference, datatype: string, cardinality: one} + - {id: programme, label: Programme, datatype: controlled-code, cardinality: one} + - {id: enrolmentStatus, label: Enrolment status, datatype: controlled-code, cardinality: one} + - {id: validThrough, label: Valid through, datatype: date, cardinality: zero-or-one} diff --git a/products/relay-v2/contracts/acceptance-scenario-matrix.yaml b/products/relay-v2/contracts/acceptance-scenario-matrix.yaml new file mode 100644 index 000000000..e189e0d26 --- /dev/null +++ b/products/relay-v2/contracts/acceptance-scenario-matrix.yaml @@ -0,0 +1,63 @@ +schemaVersion: relay.registrystack.org/acceptance-scenarios/v1alpha1 +product: relay-v2 +execution: products/relay-v2/scripts/test-http.sh +scenarios: + - {id: social-ready, project: social-assistance, journeyStep: readiness, assertion: One local protected Registry becomes ready from a generated package.} + - {id: social-lookup, project: social-assistance, journeyStep: lookup-success, assertion: A bounded protected lookup returns only its selected governed properties.} + - {id: social-lookup-default, project: social-assistance, journeyStep: lookup-default, assertion: The default representation contains exactly the compiled disclosure profile.} + - {id: social-lookup-second-subset, project: social-assistance, journeyStep: lookup-second-subset, assertion: A second valid field subset remains governed independently.} + - {id: social-lookup-jsonld, project: social-assistance, journeyStep: lookup-jsonld, assertion: JSON and JSON-LD encodings carry equivalent Registry records.} + - {id: social-route, project: social-assistance, journeyStep: route-confinement, assertion: An undeclared collection route is absent.} + - {id: social-purpose, project: social-assistance, journeyStep: wrong-purpose, assertion: An authenticated caller with the wrong trusted purpose is denied.} + - {id: social-missing-purpose, project: social-assistance, journeyStep: missing-purpose, assertion: A caller cannot omit the trusted purpose claim.} + - {id: social-missing-binding, project: social-assistance, journeyStep: missing-binding, assertion: A caller cannot omit its trusted row-binding claim.} + - {id: social-wrong-binding, project: social-assistance, journeyStep: wrong-binding, assertion: A caller bound to another authority lane cannot reveal the row.} + - {id: social-unknown-field, project: social-assistance, journeyStep: unknown-field, assertion: An unknown public property is rejected before source access.} + - {id: social-duplicate-field, project: social-assistance, journeyStep: duplicate-field, assertion: A duplicate public property selection is rejected.} + - {id: social-source-field, project: social-assistance, journeyStep: source-column-field, assertion: A source-column name cannot be selected as a public property.} + - {id: social-malformed-field, project: social-assistance, journeyStep: malformed-field, assertion: A malformed property selection is rejected.} + - {id: social-no-match, project: social-assistance, journeyStep: no-match, assertion: A missing lookup has the unresolved outcome.} + - {id: social-ambiguous, project: social-assistance, journeyStep: ambiguous, assertion: An ambiguous lookup has the unresolved outcome.} + - {id: social-hidden, project: social-assistance, journeyStep: row-hidden, assertion: A row outside the caller binding has the unresolved outcome.} + - {id: social-invalid, project: social-assistance, journeyStep: invalid-row, invalidSourceRowClass: missing-required, assertion: An invalid selected row has the unresolved outcome.} + - {id: social-excessive, project: social-assistance, journeyStep: excessive-row, invalidSourceRowClass: excessive-size, assertion: An excessively large source value fails closed as unresolved.} + - {id: social-quota, project: social-assistance, journeyStep: quota-exhausted, assertion: The named lookup fails closed after its compiled-operation quota is exhausted.} + - {id: business-discovery, project: business-registry, journeyStep: registry-discovery, assertion: Discovery derives only the public Consultation capabilities.} + - {id: business-first-page, project: business-registry, journeyStep: first-page, assertion: The snapshot list is paginated and has a cursor.} + - {id: business-second-page, project: business-registry, journeyStep: second-page, assertion: The emitted cursor retrieves a distinct second page.} + - {id: business-terminal-page, project: business-registry, journeyStep: terminal-page, assertion: A terminal page carries an explicit null next cursor.} + - {id: business-status-filter, project: business-registry, journeyStep: status-filter, assertion: The declared status equality filter is executable by itself.} + - {id: business-jurisdiction-filter, project: business-registry, journeyStep: jurisdiction-filter, assertion: The declared jurisdiction equality filter is executable by itself.} + - {id: business-filter, project: business-registry, journeyStep: filtered-page, assertion: Declared exact filters and a selected field subset remain governed.} + - {id: business-second-subset, project: business-registry, journeyStep: second-field-subset, assertion: A second valid field subset remains governed independently.} + - {id: business-read, project: business-registry, journeyStep: identifier-read, assertion: An identifier read is cacheable under the snapshot posture.} + - {id: business-read-jsonld, project: business-registry, journeyStep: identifier-read-jsonld, assertion: JSON and JSON-LD encodings carry equivalent Registry records.} + - {id: business-revalidation, project: business-registry, journeyStep: identifier-read-revalidated, assertion: A matching ETag produces an empty 304 response with the same validator.} + - {id: business-unknown-field, project: business-registry, journeyStep: unknown-field, assertion: An unknown public property is rejected before source access.} + - {id: business-duplicate-field, project: business-registry, journeyStep: duplicate-field, assertion: A duplicate public property selection is rejected.} + - {id: business-source-field, project: business-registry, journeyStep: source-column-field, assertion: A source-column name cannot be selected as a public property.} + - {id: business-malformed-field, project: business-registry, journeyStep: malformed-field, assertion: A malformed property selection is rejected.} + - {id: business-filter-rejection, project: business-registry, journeyStep: unknown-filter, assertion: An undeclared filter fails before source access.} + - {id: business-sort-rejection, project: business-registry, journeyStep: arbitrary-sort, assertion: Callers cannot choose source ordering.} + - {id: business-operator-rejection, project: business-registry, journeyStep: unsupported-filter-operator, assertion: Callers cannot add an operator to a declared direct filter.} + - {id: business-invalid, project: business-registry, journeyStep: invalid-source-row, invalidSourceRowClass: wrong-type, assertion: An invalid source row is not released.} + - {id: civil-discovery, project: civil-event, journeyStep: registry-discovery, assertion: Discovery derives retrieve and constrained search only.} + - {id: civil-read, project: civil-event, journeyStep: registrar-read, assertion: The registrar scope receives its narrower requested representation.} + - {id: civil-read-default, project: civil-event, journeyStep: registrar-read-default, assertion: The default representation contains exactly the compiled disclosure profile.} + - {id: civil-read-second-subset, project: civil-event, journeyStep: registrar-read-second-subset, assertion: A second valid field subset remains governed independently.} + - {id: civil-read-jsonld, project: civil-event, journeyStep: registrar-read-jsonld, assertion: JSON and JSON-LD encodings carry equivalent Registry records.} + - {id: civil-lookup, project: civil-event, journeyStep: lookup-success, assertion: The verification scope receives its distinct disclosure profile.} + - {id: civil-route, project: civil-event, journeyStep: no-list, assertion: A non-enumerable Registry has no collection route.} + - {id: civil-scope, project: civil-event, journeyStep: scope-separation, assertion: A lookup scope cannot synthesize identifier read.} + - {id: civil-reverse-scope, project: civil-event, journeyStep: reverse-scope-separation, assertion: A registrar read scope cannot synthesize verification lookup.} + - {id: civil-purpose, project: civil-event, journeyStep: wrong-purpose, assertion: A verification lookup refuses the wrong trusted purpose.} + - {id: civil-binding, project: civil-event, journeyStep: wrong-binding, assertion: A verification lookup hides a row outside the trusted jurisdiction binding.} + - {id: civil-unknown-field, project: civil-event, journeyStep: unknown-field, assertion: An unknown public property is rejected before source access.} + - {id: civil-duplicate-field, project: civil-event, journeyStep: duplicate-field, assertion: A duplicate public property selection is rejected.} + - {id: civil-source-field, project: civil-event, journeyStep: source-column-field, assertion: A source-column name cannot be selected as a public property.} + - {id: civil-malformed-field, project: civil-event, journeyStep: malformed-field, assertion: A malformed property selection is rejected.} + - {id: civil-hidden, project: civil-event, journeyStep: jurisdiction-hidden, assertion: A row outside the jurisdiction binding has the unresolved outcome.} + - {id: civil-no-match, project: civil-event, journeyStep: no-match, assertion: A missing civil-event lookup has the unresolved outcome.} + - {id: civil-ambiguous, project: civil-event, journeyStep: ambiguous, assertion: An ambiguous lookup has the unresolved outcome.} + - {id: civil-invalid, project: civil-event, journeyStep: invalid-row, invalidSourceRowClass: unexpected-value, assertion: An invalid source row has the unresolved outcome.} + - {id: civil-quota, project: civil-event, journeyStep: quota-exhausted, assertion: A distinct named lookup has its own bounded quota and value-free refusal.} diff --git a/products/relay-v2/contracts/artifact-inventory.yaml b/products/relay-v2/contracts/artifact-inventory.yaml new file mode 100644 index 000000000..46d7b5ed9 --- /dev/null +++ b/products/relay-v2/contracts/artifact-inventory.yaml @@ -0,0 +1,62 @@ +schemaVersion: relay.registrystack.org/artifact-inventory/v1alpha1 +product: relay-v2 +artifacts: + - id: openapi-full + mediaType: application/yaml + visibility: operator-only + source: compiled-registry + generated: true + - id: openapi-public + mediaType: application/json + visibility: public + source: compiled-registry + generated: true + - id: representation-schema + mediaType: application/schema+json + visibility: operation-compatible + source: compiled-resource + generated: true + invariant: Validates mandatory Registry Core and every allowed domainData subset. + - id: full-record-schema + mediaType: application/schema+json + visibility: operator-only + source: compiled-resource + generated: true + invariant: Validates the complete reviewed source representation before field minimization. + - id: full-record-shacl + mediaType: text/turtle + visibility: operator-only + source: compiled-resource + generated: true + invariant: Retains full source requiredness for pre-disclosure validation. + - id: semantic-model + mediaType: application/ld+json + visibility: operation-compatible + source: compiled-resource + generated: true + invariant: Defines the local vocabulary; it is not only a JSON-LD context. + - id: jsonld-context + mediaType: application/ld+json + visibility: operation-compatible + source: compiled-resource + generated: true + - id: shacl-shape + mediaType: text/turtle + visibility: operation-compatible + source: compiled-resource + generated: true + - id: codelists + mediaType: application/yaml + visibility: operation-compatible + source: governed-contract-closure + generated: false + - id: capability-inventory + mediaType: application/json + visibility: discovery-policy + source: compiled-operations + generated: true + - id: audit-event-schema + mediaType: application/schema+json + visibility: operator-only + source: relay-audit-vocabulary + generated: true diff --git a/products/relay-v2/contracts/generated-baselines.yaml b/products/relay-v2/contracts/generated-baselines.yaml new file mode 100644 index 000000000..1e832500e --- /dev/null +++ b/products/relay-v2/contracts/generated-baselines.yaml @@ -0,0 +1,600 @@ +schemaVersion: relay.registrystack.org/generated-baselines/v1alpha1 +product: relay-v2 +projects: + social-assistance: + packageRevision: sha256:cf5132c1cc009ced8d958090e940cf6d08ffd3d6a943a6d69dac3dfeb6dd28f8 + contractRevision: sha256:4fc1a040ecb0e1b699ca4872ce4b2295cc889ab1b0a3b898e1221b733da082e2 + sourceSchemaFingerprints: + assistance: sha256:256b74415d1da84efee8fecd3c59e4cebbbc99f43d77e3aa8b75e17d3b3a71d1 + artifacts: + - id: assistance-enrolment.lookup.by-case-and-person-capability + mediaType: application/json + operationIdentifier: assistance-enrolment.lookup.by-case-and-person + path: generated/artifacts/assistance-enrolment--lookup-by-case-and-person.capability.json + sha256: sha256:02c7457539602dc66db44aef46c01cd90b210867682fd743010f4a2eca0fcd95 + visibility: operation-bound + - id: assistance-enrolment--lookup-by-case-and-person-classifications + mediaType: application/json + operationIdentifier: null + path: generated/artifacts/assistance-enrolment--lookup-by-case-and-person.classifications.json + sha256: sha256:e517323c69351197244a2a7ac541ba40fe5f73ed4590b5cbe810773ed04615e1 + visibility: operator-only + - id: assistance-enrolment--lookup-by-case-and-person-context + mediaType: application/ld+json + operationIdentifier: assistance-enrolment.lookup.by-case-and-person + path: generated/artifacts/assistance-enrolment--lookup-by-case-and-person.context.jsonld + sha256: sha256:17e5183e5fb37679179920acf100116ffa514857a67796986d0a9f62b39e77d3 + visibility: operation-bound + - id: assistance-enrolment--lookup-by-case-and-person-processing + mediaType: application/json + operationIdentifier: assistance-enrolment.lookup.by-case-and-person + path: generated/artifacts/assistance-enrolment--lookup-by-case-and-person.processing.json + sha256: sha256:06d6f78f868ecadb235da94bbb57a0fec7c4ec23a5db23c859a976e542b03d7e + visibility: operation-bound + - id: assistance-enrolment--lookup-by-case-and-person-schema + mediaType: application/schema+json + operationIdentifier: assistance-enrolment.lookup.by-case-and-person + path: generated/artifacts/assistance-enrolment--lookup-by-case-and-person.schema.json + sha256: sha256:9d6bf2c2193342d763bc562939e784fdacbe1b94d7c9c7210ad52d3db6de69a6 + visibility: operation-bound + - id: assistance-enrolment--lookup-by-case-and-person-shacl + mediaType: text/turtle + operationIdentifier: assistance-enrolment.lookup.by-case-and-person + path: generated/artifacts/assistance-enrolment--lookup-by-case-and-person.shacl.ttl + sha256: sha256:8a9552f08b8dcee6a8d40bab5abe9c0f20baf542d537c8731d0c7e0c34da1cb8 + visibility: operation-bound + - id: assistance-enrolment--lookup-by-case-and-person-vocabulary + mediaType: application/ld+json + operationIdentifier: assistance-enrolment.lookup.by-case-and-person + path: generated/artifacts/assistance-enrolment--lookup-by-case-and-person.vocabulary.jsonld + sha256: sha256:d894822eb794725509df894466e19f0e96caa99e8a612a358cb1dc25b71a1a86 + visibility: operation-bound + - id: assistance-enrolment-classification + mediaType: application/json + operationIdentifier: null + path: generated/artifacts/assistance-enrolment.classifications.json + sha256: sha256:f2eb7d51bbae14ae9b8f8bdf024ea977d0f9fbe7e424c74f928b4dbe1d3d491d + visibility: operator-only + - id: assistance-enrolment-codelist-0 + mediaType: application/schema+json + operationIdentifier: null + path: generated/artifacts/assistance-enrolment.codelist-0.schema.json + sha256: sha256:a836883fac30ae1cacbd657d7429ff08f9bfff1a3b8abea0bcc4fa5401a7f200 + visibility: operator-only + - id: assistance-enrolment-codelist-1 + mediaType: application/schema+json + operationIdentifier: null + path: generated/artifacts/assistance-enrolment.codelist-1.schema.json + sha256: sha256:4dd49c40c44f8acbd56f319d4af5c9b48ffee24e5b0bd267f0c6f4833adc73d1 + visibility: operator-only + - id: assistance-enrolment-codelist-2 + mediaType: application/schema+json + operationIdentifier: null + path: generated/artifacts/assistance-enrolment.codelist-2.schema.json + sha256: sha256:e42dfbcab45a66032d126e0f203523ae44a6bc034278f2ce222f96f1ff0a78f0 + visibility: operator-only + - id: assistance-enrolment-full-schema + mediaType: application/schema+json + operationIdentifier: null + path: generated/artifacts/assistance-enrolment.full.schema.json + sha256: sha256:c53cb6cfdb9e7abaf220ed7d44cfe47c1889cb1109fbac3b6a6af43af18c8c26 + visibility: operator-only + - id: assistance-enrolment-full-shacl + mediaType: text/turtle + operationIdentifier: null + path: generated/artifacts/assistance-enrolment.full.shacl.ttl + sha256: sha256:69790667690544bbebbae987bb733ec45492a0a448902d6c964334d89ab82e34 + visibility: operator-only + - id: assistance-enrolment-full-vocabulary + mediaType: application/ld+json + operationIdentifier: null + path: generated/artifacts/assistance-enrolment.full.vocabulary.jsonld + sha256: sha256:d894822eb794725509df894466e19f0e96caa99e8a612a358cb1dc25b71a1a86 + visibility: operator-only + - id: assistance-enrolment-processing-full + mediaType: application/json + operationIdentifier: null + path: generated/artifacts/assistance-enrolment.processing.full.json + sha256: sha256:b3806fac8892ef081c3d8e26ca475fb37c3d318302f593b25828c20110a1f7b5 + visibility: operator-only + - id: audit-event-schema + mediaType: application/schema+json + operationIdentifier: null + path: generated/artifacts/audit-event.schema.json + sha256: sha256:77b868e58cf3b4e13b739d3f542a018652332fe454781f8efea0962025eccf1b + visibility: operator-only + - id: capability-inventory-full + mediaType: application/json + operationIdentifier: null + path: generated/artifacts/capabilities.full.json + sha256: sha256:02c7457539602dc66db44aef46c01cd90b210867682fd743010f4a2eca0fcd95 + visibility: operator-only + - id: capability-inventory + mediaType: application/json + operationIdentifier: null + path: generated/artifacts/capabilities.json + sha256: sha256:6e357e9fd5a858f1023b8c503a2b7def06a3eba6d2f600a72e4a31e9302acc5a + visibility: public + - id: openapi-full + mediaType: application/yaml + operationIdentifier: null + path: generated/openapi.full.yaml + sha256: sha256:58852fc5d65ff3ffcc9c89ff98786575080738ccd0f0d26cd109da58c4357b87 + visibility: operator-only + - id: openapi-public + mediaType: application/json + operationIdentifier: null + path: generated/openapi.public.json + sha256: sha256:b1a460e09d3d45200f82d9c5f44f5b7fdd2e04db4ee226e716b9104af4573ac8 + visibility: public + governedFiles: + - generated: false + mediaType: application/yaml + path: governed/codelists/enrolment-status.yaml + sha256: sha256:12b3004a2a947ebbf00769a24a0e59d72f226da2166514fa9b39c30d936844c4 + size: 94 + visibility: operator-only + - generated: false + mediaType: application/yaml + path: governed/codelists/programmes.yaml + sha256: sha256:7255a70432da192c9c782c13751552868c424652184ca66c5adb5d4acee0ee45 + size: 85 + visibility: operator-only + - generated: false + mediaType: application/yaml + path: governed/codelists/record-lifecycle.yaml + sha256: sha256:b77eab2bec905fdbb76824fc5ee717c65d4d2c4a77d67cf3a3bcbaf40040d1a4 + size: 93 + visibility: operator-only + - generated: false + mediaType: application/yaml + path: governed/governance/identifier-lifecycle.yaml + sha256: sha256:e78de15ae1dd7cf169c639483a41ee9943b064aa163c940f63ffbf6e4af9f6e2 + size: 269 + visibility: operator-only + - generated: false + mediaType: application/yaml + path: governed/governance/legal-basis.yaml + sha256: sha256:090009737bd9a730e6cf4694e4182fed5778d300885ed48678c7c4628a5c95e5 + size: 248 + visibility: operator-only + - generated: false + mediaType: application/yaml + path: registry.yaml + sha256: sha256:4e36289929d6a3129239ab8a19f3ca06a146a7fd65655c406b20830695fd62f3 + size: 5222 + visibility: operator-only + business-registry: + packageRevision: sha256:4c264d57df766f2abd8ecfd81941253002059ecc06f33e5374a47c274d3c1ef8 + contractRevision: sha256:152b2794b6e7a59ecd1a4878364f3e4c671e732d522bb4a2c970c87e2aa73fda + sourceSchemaFingerprints: + companies: sha256:c978e36d7f0de71e8aa8245cdc8501ffdd760d87d9e6e0df97fd1228712361f1 + artifacts: + - id: audit-event-schema + mediaType: application/schema+json + operationIdentifier: null + path: generated/artifacts/audit-event.schema.json + sha256: sha256:77b868e58cf3b4e13b739d3f542a018652332fe454781f8efea0962025eccf1b + visibility: operator-only + - id: capability-inventory-full + mediaType: application/json + operationIdentifier: null + path: generated/artifacts/capabilities.full.json + sha256: sha256:8e8568c166f2b24ee85d9691ba253f94d8bb739116a37e5eb0a0c411cbdf6d92 + visibility: operator-only + - id: capability-inventory + mediaType: application/json + operationIdentifier: null + path: generated/artifacts/capabilities.json + sha256: sha256:8e8568c166f2b24ee85d9691ba253f94d8bb739116a37e5eb0a0c411cbdf6d92 + visibility: public + - id: registered-business--list-classifications + mediaType: application/json + operationIdentifier: null + path: generated/artifacts/registered-business--list.classifications.json + sha256: sha256:416ca27cf218092bd39993c5cea303c42699a780bfa1c9afc70048fd93d9a5df + visibility: public + - id: registered-business--list-context + mediaType: application/ld+json + operationIdentifier: null + path: generated/artifacts/registered-business--list.context.jsonld + sha256: sha256:64bac801dca4b23b1179d6d0e024546c51dc0d910348fcb261b8e69b99aeaf41 + visibility: public + - id: registered-business--list-processing + mediaType: application/json + operationIdentifier: null + path: generated/artifacts/registered-business--list.processing.json + sha256: sha256:13db0cedf57503ac391dae286d3991500431e43a451b4be28662b98c8492b9e8 + visibility: public + - id: registered-business--list-schema + mediaType: application/schema+json + operationIdentifier: null + path: generated/artifacts/registered-business--list.schema.json + sha256: sha256:d2dbbe7436a9e9c6a148d4deb3497fd22912ca48119ed448c577777904e2c773 + visibility: public + - id: registered-business--list-shacl + mediaType: text/turtle + operationIdentifier: null + path: generated/artifacts/registered-business--list.shacl.ttl + sha256: sha256:52f7f327c7eaf3b215590b93679e6a7960218d2822f570c9971e8c001bb8706b + visibility: public + - id: registered-business--list-vocabulary + mediaType: application/ld+json + operationIdentifier: null + path: generated/artifacts/registered-business--list.vocabulary.jsonld + sha256: sha256:24bcf44aa7b04353a8a23b2d80e5c4fe1cf6a60f0b03d0f0a0c48611631ee5d7 + visibility: public + - id: registered-business--read-classifications + mediaType: application/json + operationIdentifier: null + path: generated/artifacts/registered-business--read.classifications.json + sha256: sha256:28e9931eb5c876217ddd19245160f77e18afe85bd75d0bead35218a20515a663 + visibility: public + - id: registered-business--read-context + mediaType: application/ld+json + operationIdentifier: null + path: generated/artifacts/registered-business--read.context.jsonld + sha256: sha256:64bac801dca4b23b1179d6d0e024546c51dc0d910348fcb261b8e69b99aeaf41 + visibility: public + - id: registered-business--read-processing + mediaType: application/json + operationIdentifier: null + path: generated/artifacts/registered-business--read.processing.json + sha256: sha256:5b4b29df983773701d8b6731721db7fab64224e849aa1271f5b17133fa5e5e68 + visibility: public + - id: registered-business--read-schema + mediaType: application/schema+json + operationIdentifier: null + path: generated/artifacts/registered-business--read.schema.json + sha256: sha256:483efcac14157c1277190a97930e64b814ede3718ddc7f9ed41918f3a07a7809 + visibility: public + - id: registered-business--read-shacl + mediaType: text/turtle + operationIdentifier: null + path: generated/artifacts/registered-business--read.shacl.ttl + sha256: sha256:52f7f327c7eaf3b215590b93679e6a7960218d2822f570c9971e8c001bb8706b + visibility: public + - id: registered-business--read-vocabulary + mediaType: application/ld+json + operationIdentifier: null + path: generated/artifacts/registered-business--read.vocabulary.jsonld + sha256: sha256:24bcf44aa7b04353a8a23b2d80e5c4fe1cf6a60f0b03d0f0a0c48611631ee5d7 + visibility: public + - id: registered-business-classification + mediaType: application/json + operationIdentifier: null + path: generated/artifacts/registered-business.classifications.json + sha256: sha256:ae05d0a676dded7a6f9acfe6d658a6d1bd27de3e427763f31565661f0ccccf28 + visibility: operator-only + - id: registered-business-codelist-0 + mediaType: application/schema+json + operationIdentifier: null + path: generated/artifacts/registered-business.codelist-0.schema.json + sha256: sha256:b5f27954974850cd56ec6e271a4f630ce749efc332e58b6a407ece3f943f3d20 + visibility: operator-only + - id: registered-business-codelist-1 + mediaType: application/schema+json + operationIdentifier: null + path: generated/artifacts/registered-business.codelist-1.schema.json + sha256: sha256:69064b6563a9376270b2d6535a338a5766071012817d25edb0351f8e0e65b76b + visibility: operator-only + - id: registered-business-codelist-2 + mediaType: application/schema+json + operationIdentifier: null + path: generated/artifacts/registered-business.codelist-2.schema.json + sha256: sha256:4df390c7d6dbf8dae80011b4ea93545b7f2688cc7337a0534f322a92530d3b96 + visibility: operator-only + - id: registered-business-codelist-3 + mediaType: application/schema+json + operationIdentifier: null + path: generated/artifacts/registered-business.codelist-3.schema.json + sha256: sha256:e42dfbcab45a66032d126e0f203523ae44a6bc034278f2ce222f96f1ff0a78f0 + visibility: operator-only + - id: registered-business-full-schema + mediaType: application/schema+json + operationIdentifier: null + path: generated/artifacts/registered-business.full.schema.json + sha256: sha256:7832a6823b1e637b63c0f64cdf8e78ed56033321bcdb7de6bd5315dc95bf6d77 + visibility: operator-only + - id: registered-business-full-shacl + mediaType: text/turtle + operationIdentifier: null + path: generated/artifacts/registered-business.full.shacl.ttl + sha256: sha256:3c78e27835effda7508e45a9d7de364bbf3bd7a00db84bf35ed862c679cc3a18 + visibility: operator-only + - id: registered-business-full-vocabulary + mediaType: application/ld+json + operationIdentifier: null + path: generated/artifacts/registered-business.full.vocabulary.jsonld + sha256: sha256:24bcf44aa7b04353a8a23b2d80e5c4fe1cf6a60f0b03d0f0a0c48611631ee5d7 + visibility: operator-only + - id: registered-business-processing-full + mediaType: application/json + operationIdentifier: null + path: generated/artifacts/registered-business.processing.full.json + sha256: sha256:9e14c3d53958f18e29ee021c74f6f8ea0ceacb0452d01f5f13f5ea7270006158 + visibility: operator-only + - id: openapi-full + mediaType: application/yaml + operationIdentifier: null + path: generated/openapi.full.yaml + sha256: sha256:d98d08da49feaed0a5d5512cfbf4171ce9c971b92e9591b614e735a8bed06164 + visibility: operator-only + - id: openapi-public + mediaType: application/json + operationIdentifier: null + path: generated/openapi.public.json + sha256: sha256:d98d08da49feaed0a5d5512cfbf4171ce9c971b92e9591b614e735a8bed06164 + visibility: public + governedFiles: + - generated: false + mediaType: application/yaml + path: governed/codelists/business-status.yaml + sha256: sha256:5ece98ce569218b515f0712ccb604a6bed50ba0aec100dc882d21b4ef5fbca27 + size: 97 + visibility: operator-only + - generated: false + mediaType: application/yaml + path: governed/codelists/jurisdictions.yaml + sha256: sha256:c3d4a8e7dcf74ab3034ce3ea816c476ab1201a6029dad03b188fd1493c175a89 + size: 77 + visibility: operator-only + - generated: false + mediaType: application/yaml + path: governed/codelists/legal-forms.yaml + sha256: sha256:6e70156f5503d1e97f0598f550c2bafec6674987179e1d38e391bcd44bd50edd + size: 105 + visibility: operator-only + - generated: false + mediaType: application/yaml + path: governed/codelists/record-lifecycle.yaml + sha256: sha256:f3f7e339409460ae587ec9ff0d290c08a28cc588060d9a265969f0eb809f9dff + size: 95 + visibility: operator-only + - generated: false + mediaType: application/yaml + path: governed/governance/identifier-lifecycle.yaml + sha256: sha256:4b9cf35d384254effc3e17502608195310dc208699b9b4ab93ae108f37d44c18 + size: 262 + visibility: operator-only + - generated: false + mediaType: application/yaml + path: governed/governance/legal-basis.yaml + sha256: sha256:41c664bdb8b8737940c6def0b505e5b72fe70406132abaeef805b4c66b66573c + size: 241 + visibility: operator-only + - generated: false + mediaType: application/yaml + path: governed/semantics/semic-business-alignment.yaml + sha256: sha256:69c6efc233798949fb69457583733b837ce7fa36d238828e9357533383f197c3 + size: 494 + visibility: operator-only + - generated: false + mediaType: application/yaml + path: registry.yaml + sha256: sha256:0d32692563a1dc6a33ace51f0ff06ee445a74e79357e85339894ad6b8b70257b + size: 5244 + visibility: operator-only + civil-event: + packageRevision: sha256:a93c03f70273fed9061572759f2f447b28a0ad168d976ab5fecde2aa11acddde + contractRevision: sha256:5abb84c832f86b56f24a1e4b96807d0ac2843dbba7c517bafb07fddbf5708964 + sourceSchemaFingerprints: + events: sha256:7f770d64cb19ec54caca2aa56378b13a43cd5edc206ff44b5fecc99ee9e63759 + artifacts: + - id: audit-event-schema + mediaType: application/schema+json + operationIdentifier: null + path: generated/artifacts/audit-event.schema.json + sha256: sha256:77b868e58cf3b4e13b739d3f542a018652332fe454781f8efea0962025eccf1b + visibility: operator-only + - id: capability-inventory-full + mediaType: application/json + operationIdentifier: null + path: generated/artifacts/capabilities.full.json + sha256: sha256:47f000d35d5190ccfb212da6b4a996c9c6a1e0b3f7844a72026933f9c4133535 + visibility: operator-only + - id: capability-inventory + mediaType: application/json + operationIdentifier: null + path: generated/artifacts/capabilities.json + sha256: sha256:8b8fbd251ef0b71de508f3ef87338293d3cd66de0eb9bc370656910cdf49e69b + visibility: public + - id: civil-event.lookup.verify-registration-capability + mediaType: application/json + operationIdentifier: civil-event.lookup.verify-registration + path: generated/artifacts/civil-event--lookup-verify-registration.capability.json + sha256: sha256:e10860cf67de5f7e2e8bb410abaf026f73dd3dc16af3f4f4cc02549512cb446a + visibility: operation-bound + - id: civil-event--lookup-verify-registration-classifications + mediaType: application/json + operationIdentifier: null + path: generated/artifacts/civil-event--lookup-verify-registration.classifications.json + sha256: sha256:048c5a2ef1a8cfe6c6d9b8a9faf7127387633218e3623986d22fcfa3efdce0a2 + visibility: operator-only + - id: civil-event--lookup-verify-registration-context + mediaType: application/ld+json + operationIdentifier: civil-event.lookup.verify-registration + path: generated/artifacts/civil-event--lookup-verify-registration.context.jsonld + sha256: sha256:bfa85975a2ced3cbc944a9fb0b025d5ac56f81f68347248b48779da6ca3e45de + visibility: operation-bound + - id: civil-event--lookup-verify-registration-processing + mediaType: application/json + operationIdentifier: civil-event.lookup.verify-registration + path: generated/artifacts/civil-event--lookup-verify-registration.processing.json + sha256: sha256:5a89ebc0fc172c04171c158e675054cb50334c935fd1d3bd43b387586d945268 + visibility: operation-bound + - id: civil-event--lookup-verify-registration-schema + mediaType: application/schema+json + operationIdentifier: civil-event.lookup.verify-registration + path: generated/artifacts/civil-event--lookup-verify-registration.schema.json + sha256: sha256:4b63483e4e1f259bd81ead8dbdbce1107140ede0441ace40d7e377378a0d4994 + visibility: operation-bound + - id: civil-event--lookup-verify-registration-shacl + mediaType: text/turtle + operationIdentifier: civil-event.lookup.verify-registration + path: generated/artifacts/civil-event--lookup-verify-registration.shacl.ttl + sha256: sha256:feb87a7fb9f5f3b5fb89d0cdd4f95234d8a4a26071bd840779c3c9f04c7f1579 + visibility: operation-bound + - id: civil-event--lookup-verify-registration-vocabulary + mediaType: application/ld+json + operationIdentifier: civil-event.lookup.verify-registration + path: generated/artifacts/civil-event--lookup-verify-registration.vocabulary.jsonld + sha256: sha256:cefa2debc49c0ce476bd1ba8b66865a15949058a14b9b3565337ea0d6b6a2773 + visibility: operation-bound + - id: civil-event.read-capability + mediaType: application/json + operationIdentifier: civil-event.read + path: generated/artifacts/civil-event--read.capability.json + sha256: sha256:f2bc9d449a1ba1abc34cef22337ec7c0446f390ee3b1625bfce557e1b637c205 + visibility: operation-bound + - id: civil-event--read-classifications + mediaType: application/json + operationIdentifier: null + path: generated/artifacts/civil-event--read.classifications.json + sha256: sha256:99f07ad9b7b457c1c4319d0ec4a2c76d5080008c054124c0bcfa2eee8d1135cd + visibility: operator-only + - id: civil-event--read-context + mediaType: application/ld+json + operationIdentifier: civil-event.read + path: generated/artifacts/civil-event--read.context.jsonld + sha256: sha256:35d82eab1ee218e40fe01f1981949e19b4ee38b0fde3f2f6f0e4674a75959992 + visibility: operation-bound + - id: civil-event--read-processing + mediaType: application/json + operationIdentifier: civil-event.read + path: generated/artifacts/civil-event--read.processing.json + sha256: sha256:bb69227aa405a9df91f21188037fc4004cb40102083c1045fff7e830e5a22365 + visibility: operation-bound + - id: civil-event--read-schema + mediaType: application/schema+json + operationIdentifier: civil-event.read + path: generated/artifacts/civil-event--read.schema.json + sha256: sha256:5f6ffead0961894227d73f4e95259a73d6a7f7cd1657e579d9de787a612f1546 + visibility: operation-bound + - id: civil-event--read-shacl + mediaType: text/turtle + operationIdentifier: civil-event.read + path: generated/artifacts/civil-event--read.shacl.ttl + sha256: sha256:90cfe36f1e3ea53b8c098555ef4aa048d050cae75b0a4bf18f4efd55d8a9b8ea + visibility: operation-bound + - id: civil-event--read-vocabulary + mediaType: application/ld+json + operationIdentifier: civil-event.read + path: generated/artifacts/civil-event--read.vocabulary.jsonld + sha256: sha256:6a8225b7efed28ae336c11cbeec58bc94eaf89dcd18d2a097bc76c470f33ab85 + visibility: operation-bound + - id: civil-event-classification + mediaType: application/json + operationIdentifier: null + path: generated/artifacts/civil-event.classifications.json + sha256: sha256:2439d32b87ad0cbc1f5663a30a69a65761bb265d357550ccbafe6187a0e59a9e + visibility: operator-only + - id: civil-event-codelist-0 + mediaType: application/schema+json + operationIdentifier: null + path: generated/artifacts/civil-event.codelist-0.schema.json + sha256: sha256:cbd45c06b830956e657b9e930bdd9479278f42061c7333dd5694a57a5b2a0c73 + visibility: operator-only + - id: civil-event-codelist-1 + mediaType: application/schema+json + operationIdentifier: null + path: generated/artifacts/civil-event.codelist-1.schema.json + sha256: sha256:e42dfbcab45a66032d126e0f203523ae44a6bc034278f2ce222f96f1ff0a78f0 + visibility: operator-only + - id: civil-event-codelist-2 + mediaType: application/schema+json + operationIdentifier: null + path: generated/artifacts/civil-event.codelist-2.schema.json + sha256: sha256:c770e1867500e4c771718f0d412bc92be30d138fda628a85cff787f67ec9db09 + visibility: operator-only + - id: civil-event-codelist-3 + mediaType: application/schema+json + operationIdentifier: null + path: generated/artifacts/civil-event.codelist-3.schema.json + sha256: sha256:3dd13f1498de4f4b16597ae4285412ef9e4b9859da058e45168a7de2e2252655 + visibility: operator-only + - id: civil-event-full-schema + mediaType: application/schema+json + operationIdentifier: null + path: generated/artifacts/civil-event.full.schema.json + sha256: sha256:33b85e681577c03b3a4d56ca2916d254fc85d05761556f14f171d9887d6464c1 + visibility: operator-only + - id: civil-event-full-shacl + mediaType: text/turtle + operationIdentifier: null + path: generated/artifacts/civil-event.full.shacl.ttl + sha256: sha256:0714665e47e0977b3c58650b651d098c935b25bd1d9cebc3a042275da394c795 + visibility: operator-only + - id: civil-event-full-vocabulary + mediaType: application/ld+json + operationIdentifier: null + path: generated/artifacts/civil-event.full.vocabulary.jsonld + sha256: sha256:6a8225b7efed28ae336c11cbeec58bc94eaf89dcd18d2a097bc76c470f33ab85 + visibility: operator-only + - id: civil-event-processing-full + mediaType: application/json + operationIdentifier: null + path: generated/artifacts/civil-event.processing.full.json + sha256: sha256:762086646e734b6a8248a6bb62675490edc9a303559dca7e08719925656fec40 + visibility: operator-only + - id: openapi-full + mediaType: application/yaml + operationIdentifier: null + path: generated/openapi.full.yaml + sha256: sha256:4f3acbfd7340cfab5c22f26778ed103feedd41e7fabd5f2eae3b6f10c269a146 + visibility: operator-only + - id: openapi-public + mediaType: application/json + operationIdentifier: null + path: generated/openapi.public.json + sha256: sha256:2dc557335daf6824d9a037998ef20fa1efe835c877fb46feec1e3fd2d19ac392 + visibility: public + governedFiles: + - generated: false + mediaType: application/yaml + path: governed/codelists/civil-event-types.yaml + sha256: sha256:3a7f08db1c4b675c93f33cc85ac51506d67c276d400003a10a5669073225060f + size: 83 + visibility: operator-only + - generated: false + mediaType: application/yaml + path: governed/codelists/record-lifecycle.yaml + sha256: sha256:b1cf74b2b256bc0702afb312eb75bc024ebd8196c234858208c55efbd25a13d7 + size: 95 + visibility: operator-only + - generated: false + mediaType: application/yaml + path: governed/codelists/registration-areas.yaml + sha256: sha256:7f6c0cc66d81529315bf9c9e2c5e5691fe166ebb6136283ed6ed35cf12193e9a + size: 86 + visibility: operator-only + - generated: false + mediaType: application/yaml + path: governed/codelists/registration-status.yaml + sha256: sha256:7b26678d41f6705d3bb83d274b234834f3c98f5265a93151417fe91d499d2cf4 + size: 104 + visibility: operator-only + - generated: false + mediaType: application/yaml + path: governed/governance/identifier-lifecycle.yaml + sha256: sha256:5f85e60331b58db6c85a9b54c7c9c5a6035045a4e557bd8ab83183958d15c9e4 + size: 253 + visibility: operator-only + - generated: false + mediaType: application/yaml + path: governed/governance/legal-basis.yaml + sha256: sha256:7a20ef65053c949a3a78b3ea198752de5e9533566104e69cab6d0c60e804f1cf + size: 237 + visibility: operator-only + - generated: false + mediaType: application/yaml + path: governed/semantics/publicschema-event-alignment.yaml + sha256: sha256:c89af1aae44c66ce3ef8e6a1e6e6d9b063c50559d288b7ed290dff74422ea9d9 + size: 391 + visibility: operator-only + - generated: false + mediaType: application/yaml + path: registry.yaml + sha256: sha256:123d00a7872f1655e7cfae58ec41367b8c8a8882f36dac12c29e62fc31007ef1 + size: 6915 + visibility: operator-only diff --git a/products/relay-v2/contracts/package-layout.yaml b/products/relay-v2/contracts/package-layout.yaml new file mode 100644 index 000000000..24d935e82 --- /dev/null +++ b/products/relay-v2/contracts/package-layout.yaml @@ -0,0 +1,37 @@ +schemaVersion: relay.registrystack.org/package-layout/v1alpha1 +product: relay-v2 +requiredDocuments: + - CONCEPT.md + - DEFINITION-OF-DONE.md + - CONFIGURATION-EXAMPLES.md + - IMPLEMENTATION.md + - STANDARDS-ALIGNMENT.md +requiredContracts: + - contracts/security-invariant-matrix.yaml + - contracts/artifact-inventory.yaml + - contracts/package-layout.yaml + - contracts/acceptance-scenario-matrix.yaml + - contracts/generated-baselines.yaml +acceptanceProjects: + - id: social-assistance + path: acceptance/social-assistance + registryCount: 1 + - id: business-registry + path: acceptance/business-registry + registryCount: 1 + - id: civil-event + path: acceptance/civil-event + registryCount: 1 +projectFiles: + - registry.yaml + - runtime.yaml + - fixture.sql + - expected-http.yaml +generatedFilesCommitted: false +semanticHashSnapshotsCommitted: true +generatedFilePolicy: Generated SQLite databases and runtime artifacts are built in temporary directories and are not tracked; reviewed semantic hashes and exposure metadata are committed. +excludedInputs: + - legacy GovStack Digital Registries OpenAPI + - production credentials or tokens + - real personal, organisation, or civil-event data + - Registry Relay V1 configuration diff --git a/products/relay-v2/contracts/security-invariant-matrix.yaml b/products/relay-v2/contracts/security-invariant-matrix.yaml new file mode 100644 index 000000000..1bec16dfa --- /dev/null +++ b/products/relay-v2/contracts/security-invariant-matrix.yaml @@ -0,0 +1,169 @@ +schemaVersion: relay.registrystack.org/security-invariants/v1alpha1 +product: relay-v2 +status: enforced +invariants: + - id: sec-contract-runtime-separation + threat: Deployment configuration weakens governed disclosure or authorization. + enforcementPoint: RegistryContract and RelayRuntime closed-schema compilation before readiness. + negativeCase: runtime_governed_override_is_rejected + expected: Runtime configuration cannot add or alter resources, operations, disclosure, semantics, classification, access, or metadata visibility. + evidence: config-validation + tests: + - {path: crates/registry-relay-v2/src/contract.rs, name: runtime_rejects_governed_override} + - id: sec-one-registry-boundary + threat: Source, operation, disclosure, quota, or audit state crosses resource boundaries within one compiled Registry. + enforcementPoint: Resource-qualified compiled operations and operation-keyed runtime state. + negativeCase: multi_resource_state_crosses_a_resource_boundary + expected: Multi-resource compilation and routing keep every resource's query, disclosure, authorization, quota, response, and audit state local. + evidence: compiler-and-multi-resource-acceptance + tests: + - {path: crates/registry-relay-v2/tests/multi_resource_isolation.rs, name: compiler_keeps_every_multi_resource_operation_boundary_local} + - {path: crates/registry-relay-v2/tests/multi_resource_isolation.rs, name: real_router_keeps_related_public_and_protected_resources_isolated} + - id: sec-sqlite-read-only + threat: A request mutates, attaches, extends, or escapes the reviewed SQLite source. + enforcementPoint: OS read-only access, SQLite authorizer, fixed generated statements, bound values, and resource budgets. + negativeCase: sqlite_write_attach_extension_and_unreviewed_sql_are_rejected + expected: No public request can execute a write, schema change, attachment, extension, control statement, or caller-authored SQL. + evidence: platform-sqlite-negative-tests + tests: + - {path: crates/registry-platform-sqlite/tests/kernel.rs, name: every_mutating_or_connection_widening_action_is_refused} + - {path: crates/registry-platform-sqlite/tests/kernel.rs, name: row_cell_and_response_bounds_are_enforced} + - id: sec-token-profile-closed + threat: A malformed, wrong-audience, not-yet-valid, expired, or malformed-principal token authorizes access. + enforcementPoint: JWT access-token verification and strict principal extraction before access decisions. + negativeCase: malformed_audience_time_and_principal_tokens_fail_closed + expected: Malformed JWTs, non-scalar audiences, wrong audiences, future issuance, expiry, and malformed subjects are refused as invalid credentials. + evidence: real-router-authentication-tests + tests: + - {path: crates/registry-relay-v2/tests/acceptance_http.rs, name: real_jwt_path_rejects_malformed_audience_time_and_expired_tokens} + - {path: crates/registry-relay-v2/src/auth.rs, name: malformed_subject_cannot_fall_back_to_client_identifier} + - id: sec-operation-confinement + threat: A token or request enables an operation the Registry did not compile. + enforcementPoint: Hardcoded route construction from compiled operation kinds and exact operation scopes. + negativeCase: lookup_scope_cannot_enable_list_or_read + expected: Missing routes remain absent and scopes never synthesize capabilities. + evidence: route-inventory-and-scope-tests + tests: + - {path: crates/registry-relay-v2/tests/acceptance_http.rs, name: all_three_registry_http_journeys_use_the_real_router} + - {path: crates/registry-relay-v2/tests/multi_resource_isolation.rs, name: real_router_keeps_related_public_and_protected_resources_isolated} + - id: sec-operation-quota + threat: One named lookup exhausts another resource or operation's request budget. + enforcementPoint: Runtime quota configuration compiled into an operation-keyed limiter before readiness. + negativeCase: lookup_quota_exhaustion_is_operation_scoped + expected: Every named lookup has a bounded quota and exhaustion remains isolated to its compiled operation. + evidence: real-router-quota-tests + tests: + - {path: crates/registry-relay-v2/src/server.rs, name: quota_is_scoped_to_the_compiled_operation} + - {path: crates/registry-relay-v2/tests/acceptance_http.rs, name: all_three_registry_http_journeys_use_the_real_router} + - id: sec-trusted-context + threat: Caller input fabricates purpose or row authority. + enforcementPoint: Purpose and principal row binding come only from verified claims and compiler-injected predicates. + negativeCase: headers_and_filters_cannot_satisfy_purpose_or_row_binding + expected: Caller headers, selectors, and filters never create trusted authority. + evidence: authorization-negative-tests + tests: + - {path: crates/registry-relay-v2/src/compiler.rs, name: row_authority_is_a_compiler_injected_lane_not_a_filter} + - {path: crates/registry-relay-v2/tests/acceptance_http.rs, name: trusted_purpose_and_row_binding_refusals_use_only_verified_claims} + - id: sec-disclosure-monotonic + threat: Field selection expands disclosure, changes predicates, or exposes source columns. + enforcementPoint: One compiled maximum disclosure per operation followed by subset-only projection. + negativeCase: unknown_duplicate_hidden_and_source_fields_are_rejected_before_io + expected: fields can only remove selectable domainData properties; Registry Core remains present. + evidence: disclosure-and-query-plan-tests + tests: + - {path: crates/registry-relay-v2/tests/acceptance_http.rs, name: all_three_registry_http_journeys_use_the_real_router} + - id: sec-lookup-non-enumeration + threat: Exact lookup reveals selector values, protected existence, ambiguity, policy outcomes, or invalid source data. + enforcementPoint: Bounded body parsing, bound selectors, at-most-two-row execution, and one unresolved problem contract. + negativeCase: unresolved_lookup_outcomes_are_indistinguishable + expected: No match, ambiguity, hidden Record, unknown or protected identifier, and unsafe selected row share the same value-free outcome except independent trace correlation. + evidence: real-router-collapse-tests + tests: + - {path: crates/registry-relay-v2/src/problem.rs, name: unresolved_lookup_causes_have_one_public_body} + - {path: crates/registry-relay-v2/tests/acceptance_http.rs, name: all_three_registry_http_journeys_use_the_real_router} + - id: sec-reference-visibility + threat: A Record leaks protected metadata or points its caller to an unresolvable schema or semantic model. + enforcementPoint: Compile-time visibility closure across operation, resource, schema, semantic, capability, and OpenAPI artifacts. + negativeCase: record_cannot_reference_a_less_visible_required_artifact + expected: Every successful caller can resolve safe projections of the exact schema and semantic model referenced by the Record. + evidence: metadata-exposure-inventory + tests: + - {path: crates/registry-relay-v2/src/compiler.rs, name: public_record_cannot_reference_operator_only_semantics} + - {path: crates/registry-relay-v2/tests/acceptance_http.rs, name: all_three_registry_http_journeys_use_the_real_router} + - id: sec-audit-release-gate + threat: Source access or response bytes escape without durable accountability. + enforcementPoint: Durable attempt before source I/O and durable refusal or release before returning exact held bytes. + negativeCase: audit_failure_blocks_source_access_or_release + expected: Every data operation, including public release, fails closed at the relevant audit gate. + evidence: audit-ordering-tests + tests: + - {path: crates/registry-relay-v2/tests/acceptance_http.rs, name: audit_attempt_failure_prevents_source_access} + - {path: crates/registry-relay-v2/tests/acceptance_http.rs, name: audit_terminal_failure_discards_held_record_bytes} + - id: sec-audit-correlation-and-minimization + threat: An emitted audit event cannot be correlated to the released operation or records protected request, response, source-row, or principal values. + enforcementPoint: Relay-owned closed audit vocabulary populated from compiled and verified operation context. + negativeCase: emitted_audit_disagrees_with_response_or_contains_fixture_canaries + expected: Emitted attempt and terminal events correlate by trace and request-operation identifiers, agree with the released Registry, resource, operation, contract, disclosure, selected-property, row-boundary, and source-revision context, and contain no response bytes, Record identifiers, raw principals, or fixture canaries. + evidence: real-router-recording-audit-proof + tests: + - {path: crates/registry-relay-v2/tests/multi_resource_isolation.rs, name: real_router_keeps_related_public_and_protected_resources_isolated} + - id: sec-cursor-integrity + threat: A caller tampers with pagination state or reuses it across revisions, operations, fields, filters, or authority contexts. + enforcementPoint: Authenticated opaque cursor verification and per-page reauthorization. + negativeCase: cursor_context_or_revision_change_is_rejected + expected: A cursor is usable only under its exact compiled and authorized context before expiry. + evidence: cursor-negative-tests + tests: + - {path: crates/registry-relay-v2/src/cursor.rs, name: cursor_is_opaque_and_refuses_tampering} + - {path: crates/registry-relay-v2/src/cursor.rs, name: cursor_cannot_cross_authorization_or_filter_contexts} + - id: sec-source-truthfulness + threat: Relay serves incompatible schema, mixed live state, or false revision and cache claims. + enforcementPoint: Schema fingerprint, one transaction per request, source-profile revisions, and path identity checks. + negativeCase: schema_drift_path_replacement_and_stale_cursor_fail_closed + expected: Relay reports only revisions it can establish and never claims snapshot consistency for unversioned live data. + evidence: source-profile-tests + tests: + - {path: crates/registry-platform-sqlite/tests/kernel.rs, name: a_snapshot_is_digest_bound_and_read_immutably} + - {path: crates/registry-platform-sqlite/tests/kernel.rs, name: live_reads_allow_content_updates_but_refuse_path_replacement} + - {path: crates/registry-platform-sqlite/tests/kernel.rs, name: row_cell_and_response_bounds_are_enforced} + - {path: crates/registry-relay-v2/tests/acceptance_http.rs, name: readiness_fails_value_free_for_missing_replaced_and_drifted_sources} + - {path: crates/registry-relay-v2/tests/acceptance_http.rs, name: social_live_update_is_consistent_and_truthfully_unversioned} + - id: sec-value-free-diagnostics + threat: SQL, source paths, or fixture row values appear in SQLite or adopter-tooling errors. + enforcementPoint: Typed SQLite errors and closed adopter-tooling diagnostics. + negativeCase: sqlite_and_tooling_errors_render_no_sql_paths_or_row_values + expected: SQLite errors render no SQL, paths, or bound values, and fixture-tooling failures render no source row values. + evidence: focused-error-redaction-tests + tests: + - {path: crates/registry-platform-sqlite/tests/kernel.rs, name: errors_never_render_sql_paths_or_values} + - {path: crates/registry-relay-v2/src/tooling.rs, name: errors_never_render_paths} + - {path: crates/registry-relay-v2/src/tooling.rs, name: fixture_execution_is_isolated_and_reports_no_row_values} + - id: sec-value-free-operational-logs + threat: Request paths, identifiers, query values, or dependency traces enter operational logs. + enforcementPoint: Closed Relay-owned log filter plus fixed method, route-template, status, latency, and trace dimensions. + negativeCase: hostile_log_configuration_or_request_values_cannot_widen_operational_logs + expected: Operational logging cannot enable dependency targets and never records a dynamic path segment, query string, header, body, selector, Record identifier, or principal identifier. + evidence: focused-operational-log-tests + tests: + - {path: crates/registry-relay-v2/src/main.rs, name: operational_log_filter_cannot_enable_dependency_targets} + - {path: crates/registry-relay-v2/src/server.rs, name: operational_dimensions_never_include_request_values} + - {path: crates/registry-relay-v2/src/server.rs, name: operational_trace_identifier_is_closed_and_bounded} + - id: sec-value-free-trace-context + threat: Invalid or caller-controlled trace state is normalized or reflected through ordinary and Problem response headers. + enforcementPoint: Strict lowercase version-zero traceparent parser and Relay-owned response Trace Context projection. + negativeCase: invalid_traceparent_or_caller_tracestate_is_reflected + expected: Invalid traceparent is replaced with server context, and Relay never emits caller-supplied tracestate even when it is syntactically valid. + evidence: focused-trace-context-tests + tests: + - {path: crates/registry-relay-v2/src/problem.rs, name: version_zero_traceparent_rejects_non_lowercase_hex} + - {path: crates/registry-relay-v2/src/problem.rs, name: invalid_traceparent_is_replaced_with_server_context} + - {path: crates/registry-relay-v2/src/problem.rs, name: caller_tracestate_is_never_echoed_in_ordinary_or_problem_headers} + - id: sec-unsigned-family-boundary + threat: Relay output is mistaken for portable signed Evidence or API authentication is mistaken for Identity Federation. + enforcementPoint: Product routes, dependencies, discovery, and family-alignment inventory. + negativeCase: relay_never_advertises_or_emits_unimplemented_family_artifacts + expected: Relay advertises only compiled Consultation patterns, signs no response, and exposes no credential, federation, write, notification, aggregate, or access-history surface. + evidence: family-alignment-and-route-inventory + tests: + - {path: crates/registry-relay-v2/src/artifacts.rs, name: generated_inventory_covers_required_v1_artifact_classes_only} + - {path: crates/registry-relay-v2/tests/acceptance_http.rs, name: all_three_registry_http_journeys_use_the_real_router} diff --git a/products/relay-v2/scripts/check-configs.sh b/products/relay-v2/scripts/check-configs.sh new file mode 100755 index 000000000..ea77d6f43 --- /dev/null +++ b/products/relay-v2/scripts/check-configs.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec bash "$SCRIPT_DIR/check-generated.sh" "$@" diff --git a/products/relay-v2/scripts/check-contracts.sh b/products/relay-v2/scripts/check-contracts.sh new file mode 100755 index 000000000..32d44ef15 --- /dev/null +++ b/products/relay-v2/scripts/check-contracts.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail +export PYTHONDONTWRITEBYTECODE=1 + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +python3 "$SCRIPT_DIR/validate_product.py" +bash "$SCRIPT_DIR/check-source-neutrality.sh" +python3 -m unittest "$SCRIPT_DIR/test_validate_product.py" +bash "$SCRIPT_DIR/check-configs.sh" + +echo "relay-v2 product contracts passed" diff --git a/products/relay-v2/scripts/check-exposure-inventory.sh b/products/relay-v2/scripts/check-exposure-inventory.sh new file mode 100755 index 000000000..ea77d6f43 --- /dev/null +++ b/products/relay-v2/scripts/check-exposure-inventory.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec bash "$SCRIPT_DIR/check-generated.sh" "$@" diff --git a/products/relay-v2/scripts/check-generated.sh b/products/relay-v2/scripts/check-generated.sh new file mode 100755 index 000000000..d595d8b2f --- /dev/null +++ b/products/relay-v2/scripts/check-generated.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail +export PYTHONDONTWRITEBYTECODE=1 + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +PRODUCT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +if find "$PRODUCT_DIR/acceptance" -type f \( -name '*.sqlite' -o -name '*.sqlite3' -o -name '*.db' \) -print -quit | grep -q .; then + echo "relay-v2 generated check: generated SQLite database is tracked" >&2 + exit 1 +fi + +cd "$REPO_ROOT" +production_tree="$(cargo tree --locked -p registry-relay-v2 --no-default-features -e normal,features)" +if rg -q 'registry-platform-sqlite feature "fixture"|tempfile' <<<"$production_tree"; then + echo "relay-v2 generated check: production Relay dependency graph includes fixture tooling" >&2 + exit 1 +fi +CARGO_INCREMENTAL=0 \ +CARGO_PROFILE_DEV_DEBUG=0 \ +CARGO_PROFILE_TEST_DEBUG=0 \ + cargo build --locked -p registry-relayctl + +python3 "$SCRIPT_DIR/test_adopter_workflow.py" \ + --relayctl "$REPO_ROOT/target/debug/relayctl" "$@" diff --git a/products/relay-v2/scripts/check-source-neutrality.sh b/products/relay-v2/scripts/check-source-neutrality.sh new file mode 100755 index 000000000..a6f380e54 --- /dev/null +++ b/products/relay-v2/scripts/check-source-neutrality.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PRODUCT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +forbidden='social[-_ ]?assistance|business[-_ ]?registry|civil[-_ ]?event|crvs|birth|death|household|benefit|company' + +if rg -i -l "$forbidden" \ + "$PRODUCT_DIR/../../crates/registry-relay-v2/src" \ + "$PRODUCT_DIR/../../crates/registry-relayctl/src" >/dev/null; then + echo "relay-v2 source-neutrality: acceptance-domain term in Relay V2 production source" >&2 + exit 1 +fi + +while IFS= read -r path; do + relative="${path#"$PRODUCT_DIR/"}" + case "$relative" in + acceptance/*|CONCEPT.md|DEFINITION-OF-DONE.md|CONFIGURATION-EXAMPLES.md|IMPLEMENTATION.md|README.md|STANDARDS-ALIGNMENT.md|scripts/check-source-neutrality.sh|scripts/check-generated.sh|scripts/test_adopter_workflow.py|scripts/validate_product.py|scripts/test_validate_product.py|contracts/generated-baselines.yaml|contracts/package-layout.yaml|contracts/acceptance-scenario-matrix.yaml|contracts/security-invariant-matrix.yaml) + continue + ;; + esac + echo "relay-v2 source-neutrality: domain term outside acceptance/docs: $relative" >&2 + exit 1 +done < <(rg -i -l "$forbidden" "$PRODUCT_DIR" || true) + +if rg -i -n 'legacy/generated-crud|api/legacy|test/openAPI' "$PRODUCT_DIR/acceptance" "$PRODUCT_DIR/contracts" >/dev/null; then + echo "relay-v2 source-neutrality: legacy Digital Registries OpenAPI input referenced" >&2 + exit 1 +fi + +echo "relay-v2 source-neutrality passed" diff --git a/products/relay-v2/scripts/test-http.sh b/products/relay-v2/scripts/test-http.sh new file mode 100755 index 000000000..215f4bba0 --- /dev/null +++ b/products/relay-v2/scripts/test-http.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)" + +cd "${REPO_ROOT}" +CARGO_INCREMENTAL=0 \ +CARGO_PROFILE_DEV_DEBUG=0 \ +CARGO_PROFILE_TEST_DEBUG=0 \ + cargo test --locked -p registry-relay-v2 --test acceptance_http diff --git a/products/relay-v2/scripts/test_adopter_workflow.py b/products/relay-v2/scripts/test_adopter_workflow.py new file mode 100755 index 000000000..1604048d1 --- /dev/null +++ b/products/relay-v2/scripts/test_adopter_workflow.py @@ -0,0 +1,392 @@ +#!/usr/bin/env python3 +"""Run the complete Relay V2 adopter workflow and verify reviewed outputs.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +import shutil +import sqlite3 +import subprocess +import sys +import tempfile +from pathlib import Path +from typing import Any + +import yaml + + +PRODUCT_ROOT = Path(__file__).resolve().parents[1] +REPOSITORY_ROOT = PRODUCT_ROOT.parents[1] +PROJECTS = ("social-assistance", "business-registry", "civil-event") +BASELINE_PATH = PRODUCT_ROOT / "contracts/generated-baselines.yaml" +CONFIGURATION_REFERENCE = PRODUCT_ROOT / "CONFIGURATION-EXAMPLES.md" +CONFIGURATION_MARKERS = { + "registry": "relay-v2-registry-key-paths", + "runtime": "relay-v2-runtime-key-paths", +} +SHA256 = re.compile(r"^sha256:[0-9a-f]{64}$") + + +class GateFailure(Exception): + pass + + +def run(relayctl: Path, arguments: list[str], *, expected: int = 0) -> tuple[dict[str, Any], bytes]: + completed = subprocess.run( + [str(relayctl), "--json", *arguments], + cwd=REPOSITORY_ROOT, + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + if completed.returncode != expected: + raise GateFailure( + f"relayctl {' '.join(arguments[:1])} returned {completed.returncode}, expected {expected}" + ) + if completed.stderr: + raise GateFailure(f"relayctl {' '.join(arguments[:1])} wrote to stderr") + try: + report = json.loads(completed.stdout) + except json.JSONDecodeError as error: + raise GateFailure(f"relayctl {' '.join(arguments[:1])} did not emit JSON") from error + return report, completed.stdout + + +def materialize(project: Path) -> None: + database = project / "fixture.sqlite" + connection = sqlite3.connect(database) + try: + connection.executescript((project / "fixture.sql").read_text(encoding="utf-8")) + finally: + connection.close() + database.chmod(0o444) + + +def protected_canaries(project: Path) -> set[bytes]: + sql = (project / "fixture.sql").read_text(encoding="utf-8") + values = {match.replace("''", "'") for match in re.findall(r"'((?:''|[^'])*)'", sql)} + journey = yaml.safe_load((project / "expected-http.yaml").read_text(encoding="utf-8")) + for authorization in journey.get("authorizations", {}).values(): + values.add(str(authorization.get("principal", ""))) + values.update(str(value) for value in authorization.get("claims", {}).values()) + for step in journey.get("steps", []): + values.update(str(value) for value in step.get("request", {}).get("body", {}).values()) + return {value.encode() for value in values if len(value) >= 4} + + +def assert_value_free(outputs: list[bytes], canaries: set[bytes], project: str) -> None: + for output in outputs: + for canary in canaries: + if canary in output: + raise GateFailure(f"{project}: adopter output exposed a protected fixture value") + + +def file_sha256(path: Path) -> str: + return f"sha256:{hashlib.sha256(path.read_bytes()).hexdigest()}" + + +def tree_hashes(root: Path) -> dict[str, str]: + return { + path.relative_to(root).as_posix(): file_sha256(path) + for path in sorted(root.rglob("*")) + if path.is_file() + } + + +def openapi_operations(document: dict[str, Any]) -> dict[tuple[str, str], dict[str, Any]]: + result: dict[tuple[str, str], dict[str, Any]] = {} + if document.get("openapi") != "3.1.0" or not isinstance(document.get("paths"), dict): + raise GateFailure("generated OpenAPI is not a valid 3.1 path document") + for path, path_item in document["paths"].items(): + if not isinstance(path, str) or not path.startswith("/") or not isinstance(path_item, dict): + raise GateFailure("generated OpenAPI path inventory is malformed") + for method, operation in path_item.items(): + if method not in {"get", "post"} or not isinstance(operation, dict): + raise GateFailure("generated OpenAPI contains an unsupported path item") + operation_id = operation.get("operationId") + if not isinstance(operation_id, str) or not operation_id: + raise GateFailure("generated OpenAPI operation has no operationId") + result[(path, method)] = operation + operation_ids = [operation["operationId"] for operation in result.values()] + if len(operation_ids) != len(set(operation_ids)): + raise GateFailure("generated OpenAPI operation identifiers are not unique") + return result + + +def validate_openapi(package: Path) -> None: + full = yaml.safe_load((package / "generated/openapi.full.yaml").read_text(encoding="utf-8")) + public = json.loads((package / "generated/openapi.public.json").read_text(encoding="utf-8")) + full_operations = openapi_operations(full) + public_operations = openapi_operations(public) + for key, operation in public_operations.items(): + if full_operations.get(key) != operation: + raise GateFailure("public OpenAPI is not an exact path subset of full OpenAPI") + if operation.get("security") == [{"bearerAuth": []}]: + raise GateFailure("public OpenAPI exposes a protected-only operation") + + capabilities = json.loads( + (package / "generated/artifacts/capabilities.full.json").read_text(encoding="utf-8") + ) + capability_ids = { + capability["operationIdentifier"] for capability in capabilities["capabilities"] + } + fixed_ids = { + "relay.health", + "relay.ready", + "relay.openapi.public", + "relay.registry.metadata", + "relay.resources.list", + "relay.resources.retrieve", + "relay.artifacts.retrieve", + } + full_ids = {operation["operationId"] for operation in full_operations.values()} + if full_ids != fixed_ids | capability_ids: + raise GateFailure("full OpenAPI does not exactly cover compiled capabilities and router metadata") + + public_capabilities = json.loads( + (package / "generated/artifacts/capabilities.json").read_text(encoding="utf-8") + ) + public_capability_ids = { + capability["operationIdentifier"] + for capability in public_capabilities["capabilities"] + } + public_ids = {operation["operationId"] for operation in public_operations.values()} + required_public_ids = { + "relay.health", + "relay.ready", + "relay.openapi.public", + "relay.registry.metadata", + "relay.artifacts.retrieve", + } + if not required_public_ids.issubset(public_ids): + raise GateFailure("public OpenAPI omits a required public router operation") + if public_ids - fixed_ids != public_capability_ids: + raise GateFailure("public OpenAPI capability paths do not match public discovery") + + +def validate_exposure_and_identity(package: Path, generated: Path) -> dict[str, Any]: + manifest = json.loads((package / "relay-package.json").read_text(encoding="utf-8")) + if manifest.get("packageVersion") != "relay.registrystack.org/package/v1alpha1": + raise GateFailure("sealed package has an unsupported manifest") + artifacts = manifest.get("artifacts") + files = manifest.get("files") + if not isinstance(artifacts, list) or not isinstance(files, list): + raise GateFailure("sealed package inventory is incomplete") + file_inventory = {entry["path"]: entry for entry in files} + if len(file_inventory) != len(files): + raise GateFailure("sealed package contains duplicate file inventory paths") + for entry in files: + path = package / entry["path"] + if not path.is_file() or file_sha256(path) != entry.get("sha256"): + raise GateFailure("sealed package file bytes do not match their inventory") + if not entry.get("generated") and entry.get("visibility") != "operator-only": + raise GateFailure("an authored governed file is not operator-only") + artifact_ids: set[str] = set() + for artifact in artifacts: + identifier = artifact.get("id") + path = artifact.get("path") + if identifier in artifact_ids or path not in file_inventory: + raise GateFailure("generated artifact inventory is not one-to-one") + artifact_ids.add(identifier) + file_entry = file_inventory[path] + for key in ("mediaType", "visibility", "sha256"): + if artifact.get(key) != file_entry.get(key): + raise GateFailure("artifact exposure inventory disagrees with file inventory") + visibility = artifact.get("visibility") + operation = artifact.get("operationIdentifier") + if visibility == "operation-bound" and not operation: + raise GateFailure("operation-bound artifact has no compiled operation gate") + if visibility != "operation-bound" and operation is not None: + raise GateFailure("non-operation-bound artifact carries an operation gate") + generated_path = generated / path.removeprefix("generated/") + if not generated_path.is_file() or generated_path.read_bytes() != (package / path).read_bytes(): + raise GateFailure("generated and packaged artifact bytes differ") + by_id = {artifact["id"]: artifact for artifact in artifacts} + if by_id.get("openapi-full", {}).get("visibility") != "operator-only": + raise GateFailure("full OpenAPI is not package-only") + if by_id.get("openapi-public", {}).get("visibility") != "public": + raise GateFailure("public OpenAPI is not explicitly public") + validate_openapi(package) + return manifest + + +def baseline(manifest: dict[str, Any]) -> dict[str, Any]: + return { + "packageRevision": manifest["packageRevision"], + "contractRevision": manifest["contractRevision"], + "sourceSchemaFingerprints": manifest["sourceSchemaFingerprints"], + "artifacts": manifest["artifacts"], + "governedFiles": [entry for entry in manifest["files"] if not entry["generated"]], + } + + +def run_workflow(relayctl: Path, project_name: str, root: Path) -> tuple[list[dict[str, Any]], list[bytes], dict[str, Any]]: + source = PRODUCT_ROOT / "acceptance" / project_name + project = root / "project" + previous = root / "previous" + + reports: list[dict[str, Any]] = [] + outputs: list[bytes] = [] + + def accepted(arguments: list[str]) -> dict[str, Any]: + report, output = run(relayctl, arguments) + if report.get("status") != "success" or report.get("diagnostics") != []: + raise GateFailure(f"{project_name}: relayctl {arguments[0]} refused a reviewed project") + reports.append(report) + outputs.append(output) + return report + + accepted(["init", str(project)]) + for starter in project.iterdir(): + if starter.is_dir(): + shutil.rmtree(starter) + else: + starter.unlink() + shutil.copytree(source, project, dirs_exist_ok=True) + materialize(project) + shutil.copytree(project, previous) + accepted(["inspect", str(project / "fixture.sqlite"), "--starters", str(root / "inspection")]) + check = accepted(["check", str(project), "--production"]) + accepted(["generate", str(project), "--output", str(root / "generated")]) + accepted(["test", str(project)]) + accepted(["diff", str(previous), str(project)]) + package_report = accepted(["package", str(project), "--output", str(root / "package")]) + + manifest = validate_exposure_and_identity(root / "package", root / "generated") + if package_report["details"]["manifest"] != manifest: + raise GateFailure(f"{project_name}: package report bytes and sealed manifest differ") + + drift = root / "schema-drift" + shutil.copytree(project, drift) + database = drift / "fixture.sqlite" + database.chmod(0o644) + connection = sqlite3.connect(database) + try: + connection.execute("CREATE TABLE drift_probe (identifier TEXT NOT NULL)") + connection.commit() + finally: + connection.close() + database.chmod(0o444) + refusal, refusal_output = run( + relayctl, ["check", str(drift), "--production"], expected=1 + ) + if refusal.get("status") != "refused" or not refusal.get("diagnostics"): + raise GateFailure(f"{project_name}: schema change did not fail closed") + outputs.append(refusal_output) + + key_paths = check["details"].get("configuration_key_paths") + if not isinstance(key_paths, dict): + raise GateFailure(f"{project_name}: shared check report omitted configuration key paths") + return reports + [refusal], outputs, {"manifest": manifest, "keyPaths": key_paths} + + +def documented_key_paths(text: str, marker: str) -> set[str]: + start = f"" + end = f"" + _, separator, tail = text.partition(start) + if not separator: + raise GateFailure(f"configuration reference is missing {start}") + block, separator, _ = tail.partition(end) + if not separator: + raise GateFailure(f"configuration reference is missing {end}") + paths = [ + line.strip() + for line in block.splitlines() + if line.strip() and not line.strip().startswith("```") + ] + if paths != sorted(set(paths)): + raise GateFailure(f"{marker} key paths are not unique and sorted") + return set(paths) + + +def rewrite_key_paths(text: str, marker: str, paths: set[str]) -> str: + start = f"" + end = f"" + head, separator, tail = text.partition(start) + if not separator: + raise GateFailure(f"configuration reference is missing {start}") + _, separator, rest = tail.partition(end) + if not separator: + raise GateFailure(f"configuration reference is missing {end}") + body = "\n".join(sorted(paths)) + return f"{head}{start}\n```text\n{body}\n```\n{end}{rest}" + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--relayctl", type=Path, required=True) + parser.add_argument("--write", action="store_true") + args = parser.parse_args() + relayctl = args.relayctl.resolve() + if not relayctl.is_file(): + print("relay-v2 adopter workflow: relayctl binary is missing", file=sys.stderr) + return 1 + + try: + snapshots: dict[str, Any] = {} + key_paths = {"registry": set(), "runtime": set()} + for project_name in PROJECTS: + with tempfile.TemporaryDirectory(prefix=f"relay-v2-{project_name}-first-") as first_raw: + with tempfile.TemporaryDirectory(prefix=f"relay-v2-{project_name}-second-") as second_raw: + first = Path(first_raw) + second = Path(second_raw) + first_reports, first_outputs, first_result = run_workflow( + relayctl, project_name, first + ) + second_reports, second_outputs, second_result = run_workflow( + relayctl, project_name, second + ) + if first_reports != second_reports: + raise GateFailure(f"{project_name}: adopter reports are not deterministic") + if tree_hashes(first / "generated") != tree_hashes(second / "generated"): + raise GateFailure(f"{project_name}: generated artifacts are not deterministic") + if tree_hashes(first / "package") != tree_hashes(second / "package"): + raise GateFailure(f"{project_name}: sealed package is not deterministic") + canaries = protected_canaries(PRODUCT_ROOT / "acceptance" / project_name) + assert_value_free(first_outputs + second_outputs, canaries, project_name) + snapshots[project_name] = baseline(first_result["manifest"]) + if first_result != second_result: + raise GateFailure(f"{project_name}: shared inventories are not deterministic") + for kind in key_paths: + key_paths[kind].update(first_result["keyPaths"][kind]) + + baseline_document = { + "schemaVersion": "relay.registrystack.org/generated-baselines/v1alpha1", + "product": "relay-v2", + "projects": snapshots, + } + reference = CONFIGURATION_REFERENCE.read_text(encoding="utf-8") + if args.write: + BASELINE_PATH.write_text( + yaml.safe_dump(baseline_document, sort_keys=False, width=1000), + encoding="utf-8", + ) + for kind, paths in key_paths.items(): + reference = rewrite_key_paths( + reference, CONFIGURATION_MARKERS[kind], paths + ) + CONFIGURATION_REFERENCE.write_text(reference, encoding="utf-8") + print("relay-v2 reviewed baselines and configuration key paths updated") + return 0 + + committed = yaml.safe_load(BASELINE_PATH.read_text(encoding="utf-8")) + if committed != baseline_document: + raise GateFailure("generated semantic hashes or exposure inventory drifted") + for kind, paths in key_paths.items(): + documented = documented_key_paths(reference, CONFIGURATION_MARKERS[kind]) + if documented != paths: + raise GateFailure(f"{kind} configuration key-path reference drifted") + except (GateFailure, OSError, KeyError, TypeError, yaml.YAMLError) as error: + print(f"relay-v2 adopter workflow: {error}", file=sys.stderr) + return 1 + + print("relay-v2 complete adopter workflow, exposure inventory, and baselines passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/products/relay-v2/scripts/test_validate_product.py b/products/relay-v2/scripts/test_validate_product.py new file mode 100644 index 000000000..622546242 --- /dev/null +++ b/products/relay-v2/scripts/test_validate_product.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import copy +import importlib.util +import sys +import unittest +from pathlib import Path +from unittest import mock + + +sys.dont_write_bytecode = True +SCRIPT_PATH = Path(__file__).with_name("validate_product.py") +SPEC = importlib.util.spec_from_file_location("relay_v2_validate_product", SCRIPT_PATH) +assert SPEC is not None and SPEC.loader is not None +VALIDATOR = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = VALIDATOR +SPEC.loader.exec_module(VALIDATOR) + + +class RelayV2ProductCatalogTests(unittest.TestCase): + def test_tracked_product_catalog_is_internally_complete(self) -> None: + self.assertEqual([], VALIDATOR.validate_all()) + + def test_scenario_matrix_must_bind_one_exact_journey_step(self) -> None: + original = VALIDATOR.load_yaml + + def load_with_unknown_step(path: Path): + value = copy.deepcopy(original(path)) + if path.name == "acceptance-scenario-matrix.yaml": + value["scenarios"][0]["journeyStep"] = "not-a-step" + return value + + errors: list[str] = [] + with mock.patch.object(VALIDATOR, "load_yaml", side_effect=load_with_unknown_step): + VALIDATOR.validate_catalogs(errors) + self.assertTrue(any("exact journey step" in error for error in errors), errors) + + def test_journey_authorization_references_must_resolve(self) -> None: + original = VALIDATOR.load_yaml + + def load_with_unknown_authorization(path: Path): + value = copy.deepcopy(original(path)) + if path.name == "expected-http.yaml" and path.parent.name == "civil-event": + value["steps"][0]["authorizationFixture"] = "unknown-fixture" + return value + + errors: list[str] = [] + with mock.patch.object(VALIDATOR, "load_yaml", side_effect=load_with_unknown_authorization): + VALIDATOR.validate_catalogs(errors) + self.assertTrue(any("unknown authorization fixture" in error for error in errors), errors) + + def test_each_registry_must_keep_an_invalid_source_row_refusal(self) -> None: + original = VALIDATOR.load_yaml + + def load_without_business_invalid_row(path: Path): + value = copy.deepcopy(original(path)) + if path.name == "acceptance-scenario-matrix.yaml": + for scenario in value["scenarios"]: + if scenario.get("project") == "business-registry": + scenario.pop("invalidSourceRowClass", None) + return value + + errors: list[str] = [] + with mock.patch.object(VALIDATOR, "load_yaml", side_effect=load_without_business_invalid_row): + VALIDATOR.validate_catalogs(errors) + self.assertTrue( + any("business-registry: at least one invalid source-row refusal" in error for error in errors), + errors, + ) + + def test_acceptance_must_cover_all_four_invalid_source_row_classes(self) -> None: + original = VALIDATOR.load_yaml + + def load_without_excessive_size(path: Path): + value = copy.deepcopy(original(path)) + if path.name == "acceptance-scenario-matrix.yaml": + for scenario in value["scenarios"]: + if scenario.get("invalidSourceRowClass") == "excessive-size": + scenario["invalidSourceRowClass"] = "missing-required" + return value + + errors: list[str] = [] + with mock.patch.object(VALIDATOR, "load_yaml", side_effect=load_without_excessive_size): + VALIDATOR.validate_catalogs(errors) + self.assertTrue(any("invalid source-row classes must cover" in error for error in errors), errors) + + def test_security_test_resolution_rejects_a_similar_prefix(self) -> None: + errors: list[str] = [] + VALIDATOR.executable_test_resolves( + { + "path": "crates/registry-relay-v2/src/contract.rs", + "name": "runtime_rejects_governed_override_extra", + }, + "test reference", + errors, + ) + self.assertEqual(1, len(errors), errors) + self.assertIn("exact executable test does not resolve", errors[0]) + + def test_security_test_resolution_accepts_the_exact_annotated_function(self) -> None: + errors: list[str] = [] + VALIDATOR.executable_test_resolves( + { + "path": "crates/registry-relay-v2/src/contract.rs", + "name": "runtime_rejects_governed_override", + }, + "test reference", + errors, + ) + self.assertEqual([], errors) + + def test_unannotated_function_is_not_executable_evidence(self) -> None: + errors: list[str] = [] + VALIDATOR.executable_test_resolves( + { + "path": "crates/registry-relay-v2/src/contract.rs", + "name": "valid_secret_reference", + }, + "test reference", + errors, + ) + self.assertEqual(1, len(errors), errors) + self.assertIn("exact executable test does not resolve", errors[0]) + + +if __name__ == "__main__": + unittest.main() diff --git a/products/relay-v2/scripts/validate_product.py b/products/relay-v2/scripts/validate_product.py new file mode 100644 index 000000000..6a22058d7 --- /dev/null +++ b/products/relay-v2/scripts/validate_product.py @@ -0,0 +1,327 @@ +#!/usr/bin/env python3 +"""Validate Relay V2 product catalogs and cross-file traceability. + +Contract, runtime, source-schema, generation, fixture, and packaging semantics +belong to the shared Rust tooling. This script deliberately checks only the +tracked product catalog and the references that join those catalogs together. +""" + +from __future__ import annotations + +import os +import re +import sys +from pathlib import Path +from typing import Any + +try: + import yaml +except ModuleNotFoundError as exc: # pragma: no cover - environment failure + raise SystemExit("PyYAML is required to validate Relay V2 product catalogs") from exc + + +PRODUCT_ROOT = Path(__file__).resolve().parents[1] +REPOSITORY_ROOT = PRODUCT_ROOT.parents[1] +PROJECTS = ("social-assistance", "business-registry", "civil-event") +INVALID_SOURCE_ROW_CLASSES = { + "wrong-type", + "missing-required", + "unexpected-value", + "excessive-size", +} +SIMPLE_IDENTIFIER = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") +RUST_TEST = re.compile( + r"#\[(?:tokio::)?test(?:\([^\]]*\))?\]" + r"(?:\s*#\[[^\]]+\])*\s*(?:async\s+)?fn\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(", + re.MULTILINE, +) + + +def load_yaml(path: Path) -> Any: + with path.open(encoding="utf-8") as handle: + return yaml.safe_load(handle) + + +def mapping(value: Any, label: str, errors: list[str]) -> dict[str, Any]: + if not isinstance(value, dict): + errors.append(f"{label}: expected mapping") + return {} + return value + + +def sequence(value: Any, label: str, errors: list[str]) -> list[Any]: + if not isinstance(value, list): + errors.append(f"{label}: expected sequence") + return [] + return value + + +def require_exact_keys( + value: dict[str, Any], expected: set[str], label: str, errors: list[str] +) -> None: + missing = sorted(expected - value.keys()) + unknown = sorted(value.keys() - expected) + if missing: + errors.append(f"{label}: missing keys {', '.join(missing)}") + if unknown: + errors.append(f"{label}: unknown keys {', '.join(unknown)}") + + +def executable_test_resolves(reference: Any, label: str, errors: list[str]) -> None: + test = mapping(reference, label, errors) + require_exact_keys(test, {"path", "name"}, label, errors) + raw_path = test.get("path") + name = test.get("name") + if not isinstance(raw_path, str) or not raw_path.startswith("crates/"): + errors.append(f"{label}: path must be a repository-relative crate source path") + return + if not isinstance(name, str) or not SIMPLE_IDENTIFIER.fullmatch(name): + errors.append(f"{label}: name must be one exact Rust test function") + return + path = (REPOSITORY_ROOT / raw_path).resolve() + try: + path.relative_to(REPOSITORY_ROOT.resolve()) + except ValueError: + errors.append(f"{label}: path escapes the repository") + return + if not path.is_file(): + errors.append(f"{label}: test source does not exist: {raw_path}") + return + names = RUST_TEST.findall(path.read_text(encoding="utf-8")) + if names.count(name) != 1: + errors.append(f"{label}: exact executable test does not resolve: {raw_path}::{name}") + + +def journey_steps(errors: list[str]) -> dict[str, set[str]]: + result: dict[str, set[str]] = {} + for project_name in PROJECTS: + project = PRODUCT_ROOT / "acceptance" / project_name + for required in ("registry.yaml", "runtime.yaml", "fixture.sql", "expected-http.yaml"): + if not (project / required).is_file(): + errors.append(f"{project_name}: missing required project file {required}") + journey = mapping(load_yaml(project / "expected-http.yaml"), f"{project_name} journey", errors) + authorizations = mapping( + journey.get("authorizations"), f"{project_name} journey authorizations", errors + ) + identifiers: set[str] = set() + for index, raw in enumerate( + sequence(journey.get("steps"), f"{project_name} journey steps", errors) + ): + step = mapping(raw, f"{project_name} journey step[{index}]", errors) + identifier = step.get("id") + if not isinstance(identifier, str) or not identifier or identifier in identifiers: + errors.append(f"{project_name}: journey step ids must be unique and non-empty") + continue + identifiers.add(identifier) + authorization = step.get("authorizationFixture") + if authorization is not None and authorization not in authorizations: + errors.append( + f"{project_name}: {identifier} references unknown authorization fixture {authorization}" + ) + result[project_name] = identifiers + return result + + +def validate_catalogs(errors: list[str]) -> None: + layout = mapping( + load_yaml(PRODUCT_ROOT / "contracts/package-layout.yaml"), "package layout", errors + ) + require_exact_keys( + layout, + { + "schemaVersion", + "product", + "requiredDocuments", + "requiredContracts", + "acceptanceProjects", + "projectFiles", + "generatedFilesCommitted", + "semanticHashSnapshotsCommitted", + "generatedFilePolicy", + "excludedInputs", + }, + "package layout", + errors, + ) + expected_projects = {name: f"acceptance/{name}" for name in PROJECTS} + actual_projects = { + item.get("id"): item.get("path") + for item in sequence(layout.get("acceptanceProjects"), "acceptance projects", errors) + if isinstance(item, dict) + } + if actual_projects != expected_projects: + errors.append( + f"package layout: expected acceptance projects {expected_projects}, got {actual_projects}" + ) + required_documents = set( + sequence(layout.get("requiredDocuments"), "required documents", errors) + ) + for required in { + "CONCEPT.md", + "DEFINITION-OF-DONE.md", + "CONFIGURATION-EXAMPLES.md", + "IMPLEMENTATION.md", + "STANDARDS-ALIGNMENT.md", + }: + if required not in required_documents or not (PRODUCT_ROOT / required).is_file(): + errors.append(f"package layout: missing maintained document {required}") + if layout.get("generatedFilesCommitted") is not False: + errors.append("package layout: generated runtime files must not be committed") + if layout.get("semanticHashSnapshotsCommitted") is not True: + errors.append("package layout: reviewed semantic hash snapshots must be committed") + + inventory = mapping( + load_yaml(PRODUCT_ROOT / "contracts/artifact-inventory.yaml"), + "artifact inventory", + errors, + ) + artifact_ids: set[str] = set() + for index, raw in enumerate( + sequence(inventory.get("artifacts"), "artifact inventory", errors) + ): + artifact = mapping(raw, f"artifact[{index}]", errors) + required = {"id", "mediaType", "visibility", "source", "generated"} + missing = required - artifact.keys() + if missing: + errors.append(f"artifact[{index}]: missing keys {', '.join(sorted(missing))}") + identifier = artifact.get("id") + if not isinstance(identifier, str) or not identifier or identifier in artifact_ids: + errors.append(f"artifact[{index}]: id must be unique and non-empty") + else: + artifact_ids.add(identifier) + for required in { + "openapi-full", + "openapi-public", + "representation-schema", + "full-record-schema", + "full-record-shacl", + "semantic-model", + "jsonld-context", + "shacl-shape", + "codelists", + "capability-inventory", + "audit-event-schema", + }: + if required not in artifact_ids: + errors.append(f"artifact inventory: missing {required}") + + steps = journey_steps(errors) + scenarios = mapping( + load_yaml(PRODUCT_ROOT / "contracts/acceptance-scenario-matrix.yaml"), + "scenario matrix", + errors, + ) + expected_runner = "products/relay-v2/scripts/test-http.sh" + if scenarios.get("execution") != expected_runner: + errors.append(f"scenario matrix: execution must be {expected_runner}") + runner = REPOSITORY_ROOT / expected_runner + if not runner.is_file() or not os.access(runner, os.X_OK): + errors.append("scenario matrix: executable HTTP journey runner is missing") + scenario_ids: set[str] = set() + covered: dict[str, set[str]] = {project: set() for project in PROJECTS} + invalid_classes: dict[str, set[str]] = {project: set() for project in PROJECTS} + for index, raw in enumerate(sequence(scenarios.get("scenarios"), "scenarios", errors)): + scenario = mapping(raw, f"scenario[{index}]", errors) + expected_keys = {"id", "project", "journeyStep", "assertion"} + if "invalidSourceRowClass" in scenario: + expected_keys.add("invalidSourceRowClass") + require_exact_keys(scenario, expected_keys, f"scenario[{index}]", errors) + identifier = scenario.get("id") + project = scenario.get("project") + step = scenario.get("journeyStep") + if not isinstance(identifier, str) or not identifier or identifier in scenario_ids: + errors.append(f"scenario[{index}]: id must be unique and non-empty") + else: + scenario_ids.add(identifier) + if project not in steps or step not in steps.get(project, set()): + errors.append(f"scenario[{index}]: does not resolve to an exact journey step") + elif isinstance(step, str): + covered[project].add(step) + invalid_class = scenario.get("invalidSourceRowClass") + if invalid_class is not None: + if invalid_class not in INVALID_SOURCE_ROW_CLASSES: + errors.append(f"scenario[{index}]: unknown invalid source-row class") + elif project in invalid_classes: + invalid_classes[project].add(invalid_class) + for project in PROJECTS: + if covered[project] != steps[project]: + errors.append(f"scenario matrix: {project} journey coverage is not exact") + if not invalid_classes[project]: + errors.append(f"{project}: at least one invalid source-row refusal is required") + covered_invalid_classes = set().union(*invalid_classes.values()) + if covered_invalid_classes != INVALID_SOURCE_ROW_CLASSES: + errors.append( + "acceptance journeys: invalid source-row classes must cover " + + ", ".join(sorted(INVALID_SOURCE_ROW_CLASSES)) + ) + + matrix = mapping( + load_yaml(PRODUCT_ROOT / "contracts/security-invariant-matrix.yaml"), + "security invariant matrix", + errors, + ) + invariant_ids: set[str] = set() + for index, raw in enumerate( + sequence(matrix.get("invariants"), "security invariants", errors) + ): + invariant = mapping(raw, f"security invariant[{index}]", errors) + require_exact_keys( + invariant, + { + "id", + "threat", + "enforcementPoint", + "negativeCase", + "expected", + "evidence", + "tests", + }, + f"security invariant[{index}]", + errors, + ) + identifier = invariant.get("id") + if not isinstance(identifier, str) or not identifier or identifier in invariant_ids: + errors.append(f"security invariant[{index}]: id must be unique and non-empty") + else: + invariant_ids.add(identifier) + tests = sequence(invariant.get("tests"), f"security invariant[{index}].tests", errors) + if not tests: + errors.append(f"security invariant[{index}]: exact executable tests are required") + for test_index, test in enumerate(tests): + executable_test_resolves( + test, f"security invariant[{index}].tests[{test_index}]", errors + ) + if any(str(value).strip().lower() in {"todo", "tbd"} for value in invariant.values()): + errors.append(f"security invariant[{index}]: placeholder value is prohibited") + if len(invariant_ids) < 10: + errors.append("security invariant matrix: expected at least ten concrete invariants") + + baselines = mapping( + load_yaml(PRODUCT_ROOT / "contracts/generated-baselines.yaml"), + "generated baselines", + errors, + ) + if set(mapping(baselines.get("projects"), "generated baseline projects", errors)) != set( + PROJECTS + ): + errors.append("generated baselines: all three acceptance projects must be present") + + +def validate_all() -> list[str]: + errors: list[str] = [] + validate_catalogs(errors) + return errors + + +def main() -> int: + errors = validate_all() + if errors: + for error in errors: + print(f"relay-v2 validation: {error}", file=sys.stderr) + return 1 + print("relay-v2 product catalog validation passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/release/docker/Dockerfile.relay b/release/docker/Dockerfile.relay new file mode 100644 index 000000000..d9fe0e261 --- /dev/null +++ b/release/docker/Dockerfile.relay @@ -0,0 +1,35 @@ +# syntax=docker/dockerfile:1.7@sha256:a57df69d0ea827fb7266491f2813635de6f17269be881f696fbfdf2d83dda33e + +ARG SOURCE_DATE_EPOCH=0 + +FROM debian:trixie-slim@sha256:020c0d20b9880058cbe785a9db107156c3c75c2ac944a6aa7ab59f2add76a7bd AS runtime-root +ARG SOURCE_DATE_EPOCH + +RUN --mount=type=bind,source=dist/image-bin,target=/workspace/image-bin \ + --mount=type=bind,source=LICENSE,target=/workspace/LICENSE \ + mkdir -p \ + /workspace/runtime-root/etc/relay \ + /workspace/runtime-root/licenses/relay \ + /workspace/runtime-root/usr/local/bin \ + /workspace/runtime-root/var/lib/relay/audit \ + /workspace/runtime-root/var/lib/relay/data \ + && install -m 0755 /workspace/image-bin/relay /workspace/runtime-root/usr/local/bin/relay \ + && install -m 0644 /workspace/LICENSE /workspace/runtime-root/licenses/relay/LICENSE \ + && chown -R 65532:65532 \ + /workspace/runtime-root/etc/relay \ + /workspace/runtime-root/var/lib/relay \ + && chmod 0700 /workspace/runtime-root/var/lib/relay/audit \ + && find /workspace/runtime-root -exec touch -h --date="@${SOURCE_DATE_EPOCH}" {} + + +FROM gcr.io/distroless/cc-debian13:nonroot@sha256:d97bc0a941b8d4be647dc0ee75b264ddbb772f1ac5ba690a4309c00723b23775 AS runtime + +COPY --from=runtime-root /workspace/runtime-root/ / + +WORKDIR /var/lib/relay + +EXPOSE 8080 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 CMD ["/usr/local/bin/relay", "healthcheck", "--url", "http://127.0.0.1:8080/health"] + +ENTRYPOINT ["/usr/local/bin/relay"] +CMD ["serve", "--runtime", "/etc/relay/runtime.yaml"] diff --git a/release/notes/unsafe-code-inventory.md b/release/notes/unsafe-code-inventory.md index 8564cbbb2..1cdffd5b1 100644 --- a/release/notes/unsafe-code-inventory.md +++ b/release/notes/unsafe-code-inventory.md @@ -4,9 +4,9 @@ Issue: [#202](https://github.com/registrystack/registry-stack/issues/202) Generated: 2026-07-09 -Citation refresh: 2026-07-17. This refresh verified the current source -locations; it did not rerun the historical scan or `cargo geiger` review -described below. +Citation refresh: 2026-08-10. This refresh verified the current source +locations and reviewed the new SQLite actual-handle identity check; it did not +rerun the historical `cargo geiger` review described below. This is the release-readiness inventory for first-party unsafe Rust in the Registry Stack workspace. It records where first-party release crates do not @@ -17,9 +17,8 @@ and the review status for 1.0. - Reviewed the workspace root lint policy: `Cargo.toml` sets `[workspace.lints.rust] unsafe_code = "forbid"`. -- Scanned workspace member manifests under `crates/` plus - `products/notary/xtask` for `[lints] workspace = true` and explicit - `not opted into [workspace.lints]` annotations. +- Scanned workspace member manifests under `crates/` for + `[lints] workspace = true` and explicit opt-out annotations. - Scanned the opt-out crates with `rg -n "unsafe\\s*\\{|unsafe fn|unsafe extern"`. - Ran `cargo geiger` 0.13.0 as a cross-check. The tool cannot report directly from the virtual workspace manifest, so direct package scans were used to @@ -30,7 +29,7 @@ The excluded fuzz harness manifests under `products/*/fuzz` are separate ## Current Inventory -Current scan result: four first-party workspace crates are intentionally not +Current scan result: three first-party workspace crates are intentionally not opted into `[workspace.lints]`. The older issue comment that mentioned five opt-out crates is stale for the current tree; `crates/registry-relay/Cargo.toml` now opts into workspace lints. @@ -59,98 +58,57 @@ Review notes: 1.0 status: accepted with the existing safety comments and tests that exercise alias and anchor rejection. -### `registry-notary` +### `registry-evidence-client-node` -Reason: unsafe `std::env::set_var` and `std::env::remove_var` calls in -`#[cfg(test)]` code. +Reason: napi-rs generates unsafe Node-API registration glue outside this +crate's authored source. Unsafe surface: -- `crates/registry-notary/src/doctor/tests.rs:408` -- `crates/registry-notary/src/doctor/tests.rs:429` -- `crates/registry-notary/src/doctor/tests.rs:440` -- `crates/registry-notary/src/doctor/tests.rs:460` -- `crates/registry-notary/src/doctor/tests.rs:471` -- `crates/registry-notary/src/doctor/tests.rs:493` +- `crates/registry-evidence-client-node/src/lib.rs` denies unsafe code in the + crate's own source. +- Unsafe registration glue is generated by `napi` and `napi-derive`. Review notes: -- The unsafe calls are test-only environment mutation for JWK diagnostics. -- No runtime unsafe surface was found in this crate outside test code. -- Future cleanup should move these tests behind a serialized environment helper - or a config injection path so the crate can inherit workspace lints. +- The crate is a thin Node.js binding over `registry-evidence-client`. +- The manifest documents why it cannot inherit the workspace-wide forbid lint. +- Authored binding code remains under `#![deny(unsafe_code)]`. -1.0 status: accepted as test-only unsafe. +1.0 status: accepted for generated napi-rs FFI only. -### `registry-notary-server` +### `registry-platform-sqlite` -Reason: unsafe `std::env::set_var` and `std::env::remove_var` calls in -`#[cfg(test)]` code. +Reason: SQLite exposes the `SQLITE_FCNTL_HAS_MOVED` actual-open-handle identity +check only through `sqlite3_file_control`; rusqlite does not provide a safe +wrapper for it. Unsafe surface: -- `crates/registry-notary-server/src/standalone/tests/signing.inc:455` -- `crates/registry-notary-server/src/standalone/tests/signing.inc:546` -- `crates/registry-notary-server/src/standalone/tests/signing.inc:581` -- `crates/registry-notary-server/src/standalone/tests/signing.inc:658` -- `crates/registry-notary-server/src/standalone/tests/signing.inc:767` -- `crates/registry-notary-server/src/standalone/tests/deployment_gates.rs:36` -- `crates/registry-notary-server/src/standalone/tests/deployment_gates.rs:307` -- `crates/registry-notary-server/src/standalone/tests/deployment_gates.rs:325` -- `crates/registry-notary-server/src/state_plane/migration/tests.rs:603` -- `crates/registry-notary-server/src/state_plane/migration/tests.rs:666` -- `crates/registry-notary-server/src/state_plane/migration/tests.rs:697` -- `crates/registry-notary-server/src/state_plane/migration/tests.rs:1866` -- `crates/registry-notary-server/src/state_plane/migration/tests.rs:1897` -- `crates/registry-notary-server/src/state_plane/migration/tests.rs:1930` -- `crates/registry-notary-server/src/state_plane/migration/tests.rs:1959` -- `crates/registry-notary-server/src/state_plane/migration/tests.rs:2239` -- `crates/registry-notary-server/src/state_plane/migration/tests.rs:2248` -- `crates/registry-notary-server/src/state_plane/migration/tests.rs:2250` +- `crates/registry-platform-sqlite/src/statement.rs` contains one Unix-only + `sqlite3_file_control` call in `confirm_connection_still_bound`. Review notes: -- The unsafe calls are test-only environment mutation for deployment gates, - state-plane migration and encryption checks, and PKCS#11 or key diagnostics. -- No runtime unsafe surface was found in this crate outside test code. -- Future cleanup should reduce process-wide environment mutation in tests. +- The crate denies unsafe code everywhere except this one scoped function. +- The function borrows a live rusqlite connection, passes SQLite a static + NUL-terminated database name and one valid `c_int` output pointer, checks the + return code, and does not retain or dereference SQLite's opaque handle. +- Snapshot and live profiles call the check for every pooled connection after + opening and around statement execution. Deterministic replacement tests cover + multiple connections. +- Non-Unix targets fail closed until an equivalent actual-handle proof exists. -1.0 status: accepted as test-only unsafe. - -### `registry-notary-worker-harness` - -Reason: Unix process isolation uses `pre_exec`, `setrlimit`, process-group kill, -and a minimal `kill(2)` FFI declaration. - -Unsafe surface: - -- `crates/registry-notary-worker-harness/src/lib.rs:742` installs the Unix - `pre_exec` hook. -- `crates/registry-notary-worker-harness/src/lib.rs:981` calls - `libc::setrlimit`. -- `crates/registry-notary-worker-harness/src/lib.rs:1001` kills the worker - process group on shutdown. -- `crates/registry-notary-worker-harness/src/lib.rs:1017` declares the Unix - `kill(2)` FFI. - -Review notes: - -- This unsafe code is runtime code, but it is the intended isolation boundary - for the hardened worker process pool. -- The worker command runs with a minimal environment and optional memory limits. -- The unsafe surface is Unix-specific and localized to the harness crate. - -1.0 status: accepted for the worker isolation boundary. +1.0 status: accepted as the localized SQLite VFS identity boundary. ## Review Decision -No new unsafe code is introduced by this inventory. For 1.0, the accepted -first-party unsafe surface is: +For 1.0, the accepted first-party unsafe surface is: - localized libyaml FFI in `registry-manifest-cli`; -- test-only environment mutation in `registry-notary`; -- test-only environment mutation in `registry-notary-server`; -- Unix process-control FFI in `registry-notary-worker-harness`. +- generated napi-rs FFI in `registry-evidence-client-node` while authored code + denies unsafe; +- Unix SQLite actual-handle identity FFI in `registry-platform-sqlite`. Any new first-party unsafe code must either inherit the workspace lint and fail review, or update this inventory with maintainer rationale before release. diff --git a/release/scripts/check-debian13-images.py b/release/scripts/check-debian13-images.py index f501d20ae..362435229 100755 --- a/release/scripts/check-debian13-images.py +++ b/release/scripts/check-debian13-images.py @@ -36,6 +36,7 @@ Path("crates/registry-relay/Dockerfile"), Path("crates/registry-relay/Dockerfile.demo"), Path("release/docker/Dockerfile.registry-relay"), + Path("release/docker/Dockerfile.relay"), ) # Adopter and development images. They build from source like the per-product @@ -65,6 +66,7 @@ Path("crates/registry-relay/Dockerfile.demo"), Path("release/docker/Dockerfile.registry-relay"), ) +RELAY_V2_DOCKERFILES = (Path("release/docker/Dockerfile.relay"),) FROM_RE = re.compile(r"^FROM\s+(?:--platform=\S+\s+)?(\S+)", re.MULTILINE) STAGE_NAME_RE = re.compile(r"^FROM\s+\S+\s+AS\s+(\S+)", re.MULTILINE | re.IGNORECASE) @@ -281,6 +283,29 @@ def check_repository(root: Path = ROOT) -> list[str]: failures, ) + for relative in RELAY_V2_DOCKERFILES: + text = texts[relative] + require( + text, + "/usr/local/bin/relay", + relative, + "Relay V2 binary", + failures, + ) + require( + runtime_stage(text), + 'ENTRYPOINT ["/usr/local/bin/relay"]', + relative, + "absolute Relay V2 entrypoint", + failures, + ) + require( + runtime_stage(text), + 'CMD ["serve", "--runtime", "/etc/relay/runtime.yaml"]', + relative, + "absolute Relay V2 runtime configuration binding", + failures, + ) candidate_workflow = texts[Path(".github/workflows/release-candidate.yml")] release_workflow = texts[Path(".github/workflows/release.yml")] binary_recipe = texts[Path("release/scripts/build-release-binaries.sh")] diff --git a/release/scripts/check-gates-inventory.py b/release/scripts/check-gates-inventory.py index 4cc15e482..216685e4c 100644 --- a/release/scripts/check-gates-inventory.py +++ b/release/scripts/check-gates-inventory.py @@ -91,7 +91,7 @@ ), ( "Relay all-features shard", - '"all_features": shard_name == "relay"', + '"all_features": shard_name in {"relay", "relay-v2"}', ), ("Disk-bounded Rust cache", "cache-targets: false"), ("Rust disk telemetry", "du -sh target 2>/dev/null || true"), diff --git a/release/scripts/check-release-source-model.sh b/release/scripts/check-release-source-model.sh index 83674e749..2eeea113a 100755 --- a/release/scripts/check-release-source-model.sh +++ b/release/scripts/check-release-source-model.sh @@ -60,6 +60,8 @@ require_cargo_repo "registry-stack" "${stack_root}" require_path "registry-platform crates" "${stack_root}/crates/registry-platform-authcommon" require_path "registry-manifest crates" "${stack_root}/crates/registry-manifest-core" require_path "registry-relay crate" "${stack_root}/crates/registry-relay" +require_path "registry-relay-v2 crate" "${stack_root}/crates/registry-relay-v2" +require_path "registry-relayctl crate" "${stack_root}/crates/registry-relayctl" require_path "registry-evidence crate" "${stack_root}/crates/registry-evidence" require_path "registry-evidencectl crate" "${stack_root}/crates/registry-evidencectl" require_path "registry-mint crate" "${stack_root}/crates/registry-mint" diff --git a/release/scripts/test_check_debian13_images.py b/release/scripts/test_check_debian13_images.py new file mode 100644 index 000000000..715f97363 --- /dev/null +++ b/release/scripts/test_check_debian13_images.py @@ -0,0 +1,52 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Regression tests for the maintained Debian 13 image policy.""" + +from __future__ import annotations + +import importlib.util +import tempfile +import unittest +from pathlib import Path + + +SCRIPT = Path(__file__).with_name("check-debian13-images.py") +SPEC = importlib.util.spec_from_file_location("check_debian13_images", SCRIPT) +assert SPEC and SPEC.loader +POLICY = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(POLICY) + + +class RelayV2ImagePolicyTests(unittest.TestCase): + def repository_copy(self, root: Path) -> None: + for relative in POLICY.MAINTAINED_TEXT_PATHS: + target = root / relative + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(POLICY.ROOT.joinpath(relative).read_bytes()) + + def test_relay_v2_image_is_a_required_maintained_surface(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + self.repository_copy(root) + dockerfile = root / "release/docker/Dockerfile.relay" + dockerfile.write_text( + dockerfile.read_text(encoding="utf-8").replace( + 'ENTRYPOINT ["/usr/local/bin/relay"]', + 'ENTRYPOINT ["relay"]', + ), + encoding="utf-8", + ) + + failures = POLICY.check_repository(root) + + self.assertTrue( + any( + "Dockerfile.relay" in failure + and "absolute Relay V2 entrypoint" in failure + for failure in failures + ), + failures, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/release/scripts/test_check_gates_inventory.py b/release/scripts/test_check_gates_inventory.py index 3f5010428..a8d09a82d 100644 --- a/release/scripts/test_check_gates_inventory.py +++ b/release/scripts/test_check_gates_inventory.py @@ -643,7 +643,7 @@ def test_missing_relay_advisory_checker_tests_are_reported(self) -> None: def test_missing_relay_all_features_shard_is_reported(self) -> None: classifier = self.classifier.replace( - '"all_features": shard_name == "relay"', + '"all_features": shard_name in {"relay", "relay-v2"}', '"all_features": False', ) self.assertIn( diff --git a/release/scripts/test_check_release_source_model.py b/release/scripts/test_check_release_source_model.py index 18665c097..216639a76 100644 --- a/release/scripts/test_check_release_source_model.py +++ b/release/scripts/test_check_release_source_model.py @@ -262,6 +262,8 @@ def __enter__(self) -> Path: "crates/registry-manifest-core", "crates/registry-notary-server", "crates/registry-relay", + "crates/registry-relay-v2", + "crates/registry-relayctl", "crates/registry-evidence", "crates/registry-evidencectl", "crates/registry-mint", diff --git a/release/scripts/test_registry_release.py b/release/scripts/test_registry_release.py index bf06553a3..92e35a9e4 100755 --- a/release/scripts/test_registry_release.py +++ b/release/scripts/test_registry_release.py @@ -464,6 +464,7 @@ def test_required_rust_context_aggregates_path_gated_shards(self) -> None: "rust-tests", "evidence-contracts", "relay-contracts", + "relay-v2-contracts", }, set(rust_result["needs"]), ) From acce66b79f56fdde92658d6cd6db6bdeb722598b Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 10 Aug 2026 09:32:58 +0700 Subject: [PATCH 02/24] docs(relay): add Relay V2 guide Signed-off-by: Jeremi Joslin --- docs/site/astro.config.mjs | 13 +- docs/site/scripts/check-evidence-tutorials.sh | 1 + .../scripts/information-architecture.test.mjs | 32 +- .../site/src/content/docs/configure/relay.mdx | 273 ++++++++++++++++++ .../governed-registry-publication.mdx | 181 ++++++++++++ .../relay-semantics-and-disclosure.mdx | 204 +++++++++++++ docs/site/src/content/docs/index.mdx | 63 ++-- docs/site/src/content/docs/operate/relay.mdx | 216 ++++++++++++++ .../src/content/docs/start/quickstart.mdx | 56 ++-- .../src/content/docs/start/when-to-use.mdx | 6 +- .../publish-governed-sqlite-registry.mdx | 241 ++++++++++++++++ 11 files changed, 1182 insertions(+), 104 deletions(-) create mode 100644 docs/site/src/content/docs/configure/relay.mdx create mode 100644 docs/site/src/content/docs/explanation/governed-registry-publication.mdx create mode 100644 docs/site/src/content/docs/explanation/relay-semantics-and-disclosure.mdx create mode 100644 docs/site/src/content/docs/operate/relay.mdx create mode 100644 docs/site/src/content/docs/tutorials/publish-governed-sqlite-registry.mdx diff --git a/docs/site/astro.config.mjs b/docs/site/astro.config.mjs index 5eb2dea16..de91b991c 100644 --- a/docs/site/astro.config.mjs +++ b/docs/site/astro.config.mjs @@ -360,14 +360,11 @@ export default defineConfig({ { label: 'Connect an existing registry', items: [ - { label: 'Overview', slug: 'configure' }, - { label: 'Start a spreadsheet registry', slug: 'tutorials/publish-spreadsheet-secured-registry-api' }, - { label: 'Use your own spreadsheet', slug: 'tutorials/use-your-spreadsheet' }, - { label: 'Connect an HTTP registry', slug: 'tutorials/author-registry-project' }, - { label: 'Configure OAuth client credentials', slug: 'configure/oauth-client-credentials' }, - { label: 'Add OAuth-backed Rhai', slug: 'tutorials/configure-project-script-adapter' }, - { label: 'Advanced source patterns', slug: 'explanation/integration-patterns' }, - { label: 'Configuration fields', slug: 'reference/project-configuration' }, + { label: 'Overview', slug: 'explanation/governed-registry-publication' }, + { label: 'Publish a SQLite registry', slug: 'tutorials/publish-governed-sqlite-registry' }, + { label: 'Author a Relay project', slug: 'configure/relay' }, + { label: 'Operate Relay', slug: 'operate/relay' }, + { label: 'Semantics and disclosure', slug: 'explanation/relay-semantics-and-disclosure' }, ], }, { diff --git a/docs/site/scripts/check-evidence-tutorials.sh b/docs/site/scripts/check-evidence-tutorials.sh index 791bacd4a..eda357fe9 100755 --- a/docs/site/scripts/check-evidence-tutorials.sh +++ b/docs/site/scripts/check-evidence-tutorials.sh @@ -93,6 +93,7 @@ EXCLUDED_EVIDENCE_TUTORIALS=( manage-evidence-verifier-trust # how-to against the reader's own deployment; no fixed scenario this gate can replay move-evidence-to-production-signing # drift-checked by evidence-production-build-docs.test.mjs; needs a Transit signer prove-an-evidence-project # how-to against the reader's own project; no fixed scenario this gate can replay + publish-governed-sqlite-registry # Relay V2 journey; replayed by the Relay product and real-process acceptance gates publish-spreadsheet-secured-registry-api # registryctl tutorial, replayed by check-tutorial.sh's REGISTRYCTL_TUTORIALS request-a-holder-bound-credential # draft: true, hidden from the sidebar; no verified wallet flow exists to replay rotate-evidence-signing-keys # drift-checked by evidence-production-build-docs.test.mjs; needs a deployed signing key diff --git a/docs/site/scripts/information-architecture.test.mjs b/docs/site/scripts/information-architecture.test.mjs index 8202890f2..34cb803c1 100644 --- a/docs/site/scripts/information-architecture.test.mjs +++ b/docs/site/scripts/information-architecture.test.mjs @@ -64,7 +64,7 @@ test('publishes one overview route for every task-flow section', () => { for (const [label, route] of [ ['Start', "link: '/'"], ['Answer with Evidence Gateway', "slug: 'start/evidence-quickstart'"], - ['Connect an existing registry', "slug: 'configure'"], + ['Connect an existing registry', "slug: 'explanation/governed-registry-publication'"], ['Operate', "slug: 'operate'"], ['Security', "slug: 'security'"], ['Reference', "slug: 'reference'"], @@ -75,36 +75,36 @@ test('publishes one overview route for every task-flow section', () => { } }); -test('groups Relay tutorials under existing registries', () => { +test('keeps the compact Relay V2 reader journey under existing registries', () => { const start = topLevelSection(sidebarSource, 'Start'); assert.doesNotMatch( start, /slug: 'tutorials\//, ); const connect = topLevelSection(sidebarSource, 'Connect an existing registry'); - assert.match( - connect, - /label: 'Start a spreadsheet registry', slug: 'tutorials\/publish-spreadsheet-secured-registry-api'/, - ); - assert.match( - connect, - /label: 'Use your own spreadsheet', slug: 'tutorials\/use-your-spreadsheet'/, - ); - assert.match( + assertOrdered( connect, - /label: 'Connect an HTTP registry', slug: 'tutorials\/author-registry-project'/, + [ + "slug: 'explanation/governed-registry-publication'", + "slug: 'tutorials/publish-governed-sqlite-registry'", + "slug: 'configure/relay'", + "slug: 'operate/relay'", + "slug: 'explanation/relay-semantics-and-disclosure'", + ], + 'Relay V2 reader journey', ); + assert.doesNotMatch(connect, /registryctl|author-registry-project|publish-spreadsheet/); assert.doesNotMatch(connect, /verify-opencrvs-claims/); assert.match( homepageSource, - /\]\(tutorials\/publish-spreadsheet-secured-registry-api\/\)/, + /\]\(tutorials\/publish-governed-sqlite-registry\/\)/, ); assert.match( quickstartSource, - /\]\(\.\.\/\.\.\/tutorials\/publish-spreadsheet-secured-registry-api\/\)/, + /\]\(\.\.\/\.\.\/tutorials\/publish-governed-sqlite-registry\/\)/, ); - assert.match(homepageSource, /\]\(tutorials\/author-registry-project\/\)/); - assert.match(quickstartSource, /\]\(\.\.\/\.\.\/tutorials\/author-registry-project\/\)/); + assert.match(homepageSource, /\]\(configure\/relay\/\)/); + assert.match(quickstartSource, /\]\(\.\.\/\.\.\/configure\/relay\/\)/); assert.doesNotMatch(homepageSource, /tutorials\/verify-claim-registry-api/); assert.doesNotMatch(quickstartSource, /tutorials\/verify-claim-registry-api/); }); diff --git a/docs/site/src/content/docs/configure/relay.mdx b/docs/site/src/content/docs/configure/relay.mdx new file mode 100644 index 000000000..07c32b57d --- /dev/null +++ b/docs/site/src/content/docs/configure/relay.mdx @@ -0,0 +1,273 @@ +--- +title: Author a Registry Relay project +description: Bind one Registry contract to reviewed SQLite views, operations, semantics, classifications, fixtures, and a deployment runtime. +status: draft +owner: registry-docs +source_repos: + - registry-stack +last_reviewed: "2026-08-10" +doc_type: how-to +locale: en +standards_referenced: + - openapi + - json-schema + - json-ld + - shacl + - govstack-digital-registries +--- + +A Registry Relay project describes one authoritative Registry and binds its +governed resources to SQLite views. The contract owns meaning, disclosure, +operations, access rules, and metadata visibility. The runtime owns local +paths, the listener, one optional token issuer, audit storage, secrets, and +process limits. `relayctl check` compiles both without allowing runtime values +to override Registry policy. + +## Use the authoring lifecycle + +Relay has one compact lifecycle: + +```text +init -> inspect -> check -> generate -> test -> diff -> package +``` + +| Command | Purpose | +| --- | --- | +| `relayctl init ` | Create a complete neutral project with visibly unreviewed starters. | +| `relayctl inspect ` | Read SQLite structure without sampling row values. | +| `relayctl check ` | Compile and validate the contract, runtime, source schema, and governed files. | +| `relayctl generate ` | Reproduce API, semantic, governance, and validation artifacts. | +| `relayctl test ` | Run synthetic HTTP journeys through the shared Relay kernel. | +| `relayctl diff ` | Classify meaning, disclosure, access, source, and semantic changes. | +| `relayctl package --output ` | Create a sealed, deterministic deployment package. | + +Use `--json` when CI needs the shared typed report. Exit `1` means the project +was refused, `2` means the invocation was invalid, and `3` means an operational +failure prevented the command from completing +(`crates/registry-relayctl/src/lib.rs`). + +## Start from structure, not row values + +Inspect the source database before writing public names: + +```sh +relayctl inspect /srv/registry/business.sqlite \ + --starters ./business-registry/inspection +``` + +Inspection reports tables, views, columns, declared SQLite types, nullability, +key membership, and the schema fingerprint. Inspection never reads row values. +Generated property, semantic, and classification material remains unreviewed +until an author accepts or replaces it. + +Create narrow views in the source database before binding a resource. A view +is the source disclosure boundary: exclude internal columns, normalize codes, +and expose stable record context there. Relay does not accept caller-selected +tables, joins, expressions, columns, or ordering. + +## Describe one Registry + +The top of `registry.yaml` identifies the institution-owned Registry: + +```yaml +apiVersion: relay.registrystack.org/v2alpha1 +kind: RegistryContract +metadata: + id: registered-businesses + version: 2026-08-01 + title: Registered businesses +registry: + registryIdentifier: urn:example:registry:registered-businesses + name: Registered business Registry + authority: + identifier: urn:example:institution:company-registrar + name: Company Registrar + operator: + identifier: urn:example:institution:digital-service-operator + name: Digital Service Operator + authoritativeScope: Legal business registrations in the declared jurisdiction + baseUri: https://business.example.invalid/registry/ + identifierLifecyclePolicyRef: governance/identifier-lifecycle.yaml +``` + +One process serves one Registry. Related resources can share that Registry +when they have the same Authority and authoritative scope. A resource is a +governed Record type within the Registry, not a SQLite table and not a second +Registry. + +Keep these roles distinct: + +- The Registry Authority is accountable for the Registry in its declared + scope. +- The privacy controller determines processing responsibilities. +- The publisher authorizes publication. +- The operator runs the technical service. +- The audit owner controls retained access evidence. + +## Bind Registry Core and domain properties + +Every resource binds one reviewed source view and four source-backed Registry +Core values: + +```yaml +resources: + - id: registered-business + title: Registered business + semanticClass: local:RegisteredBusiness + source: + source: companies + view: relay_registered_businesses + recordContext: + recordIdentifier: {sourceColumn: registration_number} + revisionIdentifier: {sourceColumn: record_revision} + lifecycleState: + sourceColumn: lifecycle_state + codelist: codelists/record-lifecycle.yaml + recordedAt: {sourceColumn: recorded_at} +``` + +Relay adds the Registry identifier, Authority identifier, schema reference, +and semantic-model reference from the contract. Callers cannot remove this +context with `fields`. `recordedAt` means the authoritative revision-recorded +time, not Relay startup, snapshot, or response time. + +Declare each public property separately from its source column: + +```yaml +properties: + legalName: + sourceColumn: legal_name + type: string + sourceRequired: true + semanticTerm: local:legalName + label: Legal name + description: Registered legal name of the organisation. + classification: + privacy: potentially-personal + institutional: public-by-law +``` + +The property name is the stable public key. The source column remains an +operator detail. `sourceRequired` validates the complete source Record. The +generated response schema permits an authorized caller to omit selectable +domain properties while preserving Registry Core. + +## Compile only needed operations + +A resource can declare identifier read, deterministic list, named exact +lookup, or an appropriate subset. Operations compile into Consultation +capabilities: + +```yaml +disclosureProfiles: + public-register: + properties: + - registrationNumber + - legalName + - registrationStatus +operations: + list: + access: public + disclosureProfile: public-register + filters: + - name: status + property: registrationStatus + type: controlled-code + allowUnfiltered: true + orderBy: [registrationNumber] + pagination: + defaultPageSize: 25 + maximumPageSize: 100 + read: + access: public + disclosureProfile: public-register +``` + +List filters are typed equality parameters with direct camelCase names. Relay +reserves `pageSize`, `cursor`, and `fields`. The caller cannot add an operator, +sort, join, source column, or expression. + +Use a named exact lookup for personal or sensitive selectors. The lookup +declares its complete bounded body, one scope, optional trusted purpose, an +optional verified-claim row boundary, and a maximum of one result. Lookup-only +resources compile no collection route and no identifier-read route. + +## Classify every reviewed column + +Classification belongs to the published property. Record-context, selector, +row-binding, filter, and ordering columns that are not properties need a +`sourceColumnClassifications` entry. Resource defaults reduce repetition, and +the compiler expands them into an effective classification for every reviewed +column. + +The initial handling order is `public`, `internal`, `confidential`, and +`restricted`. A more restrictive property or hidden column can narrow an +operation but cannot widen one. `restricted` data cannot appear in a list. +Production checks refuse unclassified published properties and unclassified +reviewed columns. + +## Make semantics useful from the first contract + +Set a stable local vocabulary base and give every resource and property a +local semantic identifier: + +```yaml +semantics: + localVocabulary: https://business.example.invalid/vocabulary/ +``` + +Relay generates the local vocabulary, JSON-LD context, JSON Schema, SHACL +shape, and codelist schemas from the compiled contract. External mappings are +optional governed files. Each mapping pins its profile, version, digest, and +relation strength. A generated local term never claims automatic equivalence +with SEMIC, PublicSchema, schema.org, or another vocabulary. + +## Describe processing and metadata visibility + +Bind processing intent to the operations it governs: + +```yaml +processingDescriptions: + - id: statutory-publication + operationRefs: [list, read] + purpose: statutory-publication + recipientClass: public + legalBasisRef: governance/legal-basis.yaml + dpvProfileRef: governance/legal-basis.yaml + safeguards: + - property-minimization + - deterministic-pagination + - change-impact-review +``` + +`metadataVisibility` independently classifies service, resource, semantic, +classification, and processing metadata as `public`, `operation-bound`, or +`operator-only`. A Record audience must be able to retrieve safe projections +of the exact schema and semantic model referenced by that Record. A public +resource cannot make a protected sibling's artifacts public. + +## Validate and review a change + +Run the focused loop after every contract or source-view change: + +```sh +relayctl check ./business-registry --production +relayctl generate ./business-registry +relayctl test ./business-registry +relayctl diff ./approved-business-registry ./business-registry +``` + +Review the diff when it reports a new property, weaker classification, broader +operation, removed row binding, expanded purpose or scope, changed view, or +semantic mapping change. Git and CI remain the approval workflow. Relay has no +administration UI or approval service. + +Create the candidate only after the source schema, governed files, generated +artifacts, and fixtures pass review: + +```sh +relayctl package ./business-registry --output ./candidate-package +``` + +Continue with [Operate Registry Relay](../../operate/relay/) for the runtime, +source profiles, secret bindings, and startup ceremony. diff --git a/docs/site/src/content/docs/explanation/governed-registry-publication.mdx b/docs/site/src/content/docs/explanation/governed-registry-publication.mdx new file mode 100644 index 000000000..392e5a698 --- /dev/null +++ b/docs/site/src/content/docs/explanation/governed-registry-publication.mdx @@ -0,0 +1,181 @@ +--- +title: How Relay publishes a governed Registry +description: The Registry, resource, compiler, Consultation-family, and product boundaries that distinguish Relay from a REST wrapper over SQLite. +status: draft +owner: registry-docs +source_repos: + - registry-stack +last_reviewed: "2026-08-10" +doc_type: explanation +locale: en +standards_referenced: + - openapi + - govstack-digital-registries + - universal-dpi-safeguards +--- + +Registry Relay publishes one institution-owned Registry as a small set of +governed, semantically described, read-only resources. SQLite is the first +source adapter, not the product identity. The product is the compiled agreement +between Registry identity, meaning, disclosure, authorization, provenance, +documentation, and runtime behavior. + +The concise promise is: + +> Give Relay an authoritative SQLite database and a Registry contract. Relay +> produces a protected registry API whose meaning, disclosure, authorization, +> provenance, and documentation agree by construction. + +## Start with the Registry, not the database + +A Relay process serves exactly one Registry in one administrative trust +domain. The Registry has a stable identifier, name, Authority, optional +operator, authoritative scope, base URI, and declared standards-alignment +targets. + +A resource is a governed Record type within that Registry. A resource is not a +table and not another Registry. One view can support several resources, and +several source tables can feed one reviewed view. Database objects without a +contract binding remain invisible. + +Every returned Record carries two layers: + +- Registry Core context identifies the Registry, Record, revision, lifecycle + state, Authority, recorded time, schema, and semantic model. +- `domainData` contains only the properties permitted by the operation's + disclosure profile and optional caller minimization. + +The pair `(registryIdentifier, recordIdentifier)` identifies a Record. A +JSON-LD `@id` can provide a global IRI, but does not replace either +authoritative identifier. + +## Compile one agreement + +```mermaid +flowchart LR + contract["Registry contract
identity · resources · access · disclosure"] + sqlite[("Reviewed SQLite views")] + compiler["Relay compiler"] + package["Sealed package
query plans · artifacts · revisions"] + runtime["Relay runtime
auth · audit · limits"] + api["Registry API
JSON · JSON-LD · discovery"] + + contract --> compiler + sqlite --> compiler + compiler --> package + package --> runtime + sqlite --> runtime + runtime --> api +``` + +The compiler resolves source bindings, validates the schema fingerprint, +expands classifications, fixes query plans, derives access and disclosure +plans, and generates OpenAPI and semantic artifacts. The sealed package binds +those outputs to governed-file digests and one contract revision. + +The runtime never reconstructs policy from request parameters. The caller can +choose only a compiled operation, declared equality filters, a bounded page +size and cursor, and fewer properties from the authorized disclosure profile. + +## Expose only the needed Consultation patterns + +Relay uses API families as external capability and trust groupings. Families +are not internal crates, service names, or URL prefixes. Version 1 compiles +three Consultation patterns: + +| Authored operation | Advertised pattern | Boundary | +| --- | --- | --- | +| Identifier read | `consultation.retrieve` | One Record by its stable identifier. | +| Deterministic list | `consultation.list` | A bounded collection with declared filters and ordering. | +| Named exact lookup | `consultation.search` | One resolved Record or one indistinguishable unresolved outcome. | + +Named exact lookup is not Record Match. Relay returns no candidate list, +confidence score, ranking, or matching explanation. A lookup-only sensitive +Registry compiles no list or identifier-read route, even when a token carries +broader scopes. + +The service document at `GET /v2` derives its visible capability inventory +from the compiled operations. A deployment advertises only the patterns it +implements. The current GovStack Digital Registries and API Design Guide drafts +are directional inputs. Generated material is alignment evidence, not a +conformance or certification claim +(`products/relay-v2/STANDARDS-ALIGNMENT.md`). + +## Keep access and disclosure separate + +A protected request crosses distinct gates: + +1. Strict JWT verification establishes one principal, audience, issuer, + lifetime, token identifier, and scopes. +2. The compiled access rule requires one operation scope and can require a + trusted purpose and an authority-to-row binding. +3. Relay builds a fixed parameterized query over the reviewed view. +4. The selected source Record passes complete source-shape validation. +5. The disclosure plan emits the operation's maximum property set or a + caller-requested subset. +6. Durable terminal audit succeeds before the held response bytes are + released. + +Purpose and row authority come from verified claims named by the contract. +Caller headers and query parameters cannot create authority. Different +operations can have different disclosure profiles. Version 1 does not provide +dynamic per-client property permissions within one operation. + +## Make public and protected metadata follow the same boundary + +Registry identity is public. Resource, schema, semantic, classification, and +processing artifacts can be public, operation-bound, or operator-only. +Operation-bound artifacts use the same access gate as the Record that links +them. A public sibling resource cannot reveal a protected resource's existence +or artifacts. + +Relay publishes a safe public OpenAPI projection and retains the full OpenAPI +document in the sealed package. The public document omits protected selector +shapes and operator-only metadata. Relay does not create caller-specific +OpenAPI at request time. + +## Understand the source profiles + +Snapshot mode captures an immutable read-only SQLite file with stable identity, +digest, and reproducible source revision. Live read-only mode permits a +separate trusted publisher to update the database while Relay keeps one fixed +contract and one consistent transaction per request. + +Snapshot is stronger but optional. Version 1 live sources are unversioned, +support read and named lookup only, and return no ETag or cacheable response. +Both profiles deny writes, arbitrary SQL, undeclared functions, schema drift, +unbounded rows, and unbounded response values. + +## Keep Relay, Evidence, and Mint distinct + +Relay responses are unsigned. TLS protects transport, OAuth protects +controlled operations, and revisions plus tamper-evident audit support +accountability. + +Evidence Gateway remains the product for a portable signed, +minimum-disclosure assertion. Evidence can later consume a Relay-protected +exact lookup as an ordinary fixed HTTP source without moving signing into +Relay. Registry Mint is an optional OAuth issuer when an institution lacks a +suitable authorization server. Relay has no production dependency on either +product. + +## Know the product boundary + +Relay is a governed semantic Registry publisher and a protected read-only API +over existing authoritative data. Relay is not: + +- a generic SQLite REST generator or SQL proxy; +- a write API, registry administration service, or workflow engine; +- an RDF store, SPARQL endpoint, or runtime inference engine; +- a general policy engine, consent system, or identity provider; +- a matching, eligibility, case-management, aggregate, or analytics service; +- a credential issuer or signed-assertion service; +- a multi-Registry hosting layer. + +PostgreSQL, GeoJSON and SpatiaLite, additional API families, and richer +semantic profiles remain later extensions. Version 1 does not introduce a +generic storage abstraction before a second adapter proves the boundary. + +Continue with [Publish a governed SQLite registry](../../tutorials/publish-governed-sqlite-registry/) +for a complete first run, or [review semantics, classification, and disclosure](../relay-semantics-and-disclosure/) +for the metadata and minimization model. diff --git a/docs/site/src/content/docs/explanation/relay-semantics-and-disclosure.mdx b/docs/site/src/content/docs/explanation/relay-semantics-and-disclosure.mdx new file mode 100644 index 000000000..f9a679283 --- /dev/null +++ b/docs/site/src/content/docs/explanation/relay-semantics-and-disclosure.mdx @@ -0,0 +1,204 @@ +--- +title: Semantics, classification, and disclosure in Relay +description: How Relay creates useful local semantics, keeps external mappings optional, classifies reviewed columns, and compiles narrow representations. +status: draft +owner: registry-docs +source_repos: + - registry-stack +last_reviewed: "2026-08-10" +doc_type: explanation +locale: en +standards_referenced: + - json-schema + - json-ld + - shacl + - govstack-digital-registries + - universal-dpi-safeguards +--- + +Relay treats semantics and classification as inputs to a safe public contract, +not as catalog decoration. An adopter can begin without an existing JSON-LD +context, SHACL shape, or vocabulary mapping. Relay generates a useful local +model from the reviewed Registry contract, while external alignments remain +optional, pinned, and explicit. + +## Separate four layers of meaning + +One SQLite column can participate in several distinct concerns: + +| Layer | Question | Example | +| --- | --- | --- | +| Source binding | Where does the value come from? | `legal_name` in `relay_registered_businesses`. | +| Domain meaning | What does the published property mean? | `local:legalName`. | +| Classification | How sensitive is the property and how must Relay handle it? | `potentially-personal`, `public-by-law`, `public`. | +| Processing description | Why is an operation offered, to which recipient class, and with which safeguards? | Statutory publication to the public. | + +The public property is the centre of the model. A source column is its local +binding, not its API name or semantic identity. This distinction allows a +property to be renamed, derived, combined, or reused under different +disclosure profiles without publishing storage internals. + +## Generate local semantics first + +Every Registry contract declares a stable local vocabulary base. Every +resource names a local class, and every property names a local term. From that +small authored model, Relay generates: + +- a local JSON-LD vocabulary with classes, properties, labels, descriptions, + datatypes, source requiredness, and codelist references; +- one JSON-LD context per visible operation; +- a JSON Schema for each permitted response representation; +- a SHACL shape for each operation and a complete operator-only source shape; +- codelist schemas and links; +- capability, classification, and processing artifacts. + +The generated model is useful without an external mapping. Stable local terms +make Records interpretable and give later mapping work an explicit source +vocabulary. Relay never guesses that two terms are equivalent. + +## Add external alignments deliberately + +An institution can add a governed mapping file when a suitable public +vocabulary exists. The business acceptance Registry demonstrates a small SEMIC +Core Business Vocabulary alignment: + +```yaml +schemaVersion: relay.registrystack.org/semantic-alignment/v1alpha1 +profile: https://semiceu.github.io/Core-Business-Vocabulary/ +profileVersion: reviewed-2026-08-09 +profileDigest: sha256: +status: illustrative-pinned-input +mappings: + - local: local:RegisteredBusiness + external: https://data.europa.eu/m8g/LegalEntity + relation: close + - local: local:legalName + external: https://data.europa.eu/m8g/legalName + relation: close +``` + +Each mapping names an exact, close, broad, narrow, or related relation. The +profile version and digest make an external vocabulary change reviewable. +Relay compiles the file but never fetches or infers from remote vocabulary +content at runtime. + +SEMIC, PublicSchema, schema.org, and domain vocabularies are possible mapping +targets, not mandatory dependencies. A local term remains authoritative for +the Relay contract even when an external relation exists. + +## Keep source validation and response validation distinct + +The complete source Record and a caller-minimized response have different +requiredness rules. + +The operator-only full schema and SHACL shape validate every `sourceRequired` +property and Registry Core binding in the reviewed view. An invalid selected +source row is not partially released or coerced. No match, ambiguity, a hidden +row, and an unsafe row share the same unresolved public outcome where the +lookup contract requires indistinguishability. + +An operation's response schema always requires Registry Core. Domain +properties are constrained when present but can be omitted through the +`fields` parameter. The schema reference returned with a Record therefore +validates the representation the caller actually received, not an imaginary +full row. + +## Classify properties and hidden columns + +Each published property carries three classification dimensions: + +- privacy category describes whether a value is personal, identifying, + sensitive, derived, or another governed category; +- institutional classification uses the Registry Authority's own scheme; +- technical handling selects the controls Relay must apply. + +The initial handling vocabulary is ordered from `public` through `internal` +and `confidential` to `restricted`. The compiler applies the most restrictive +effective handling across selected properties and all source columns used by +the operation. + +Hidden columns still matter. Record revision, lifecycle, recorded time, +selectors, row bindings, filters, and ordering can affect an access or release +decision without appearing in `domainData`. Every reviewed hidden column needs +a technical classification. This closes the gap where a non-returned selector +or row-boundary column could be treated as harmless because it was absent from +the response. + +Resource defaults keep authoring compact. The compiler expands defaults and +property or column exceptions into a complete effective classification. +Generated classifications remain suggestions until reviewed, and the +production profile refuses incomplete classification. + +## Let handling narrow access, never widen it + +Classification is monotonic for security: + +- `public` data can be anonymous only through an explicitly public operation; +- non-public handling requires authentication, an operation scope, + `Cache-Control: no-store`, and durable value-free audit; +- `confidential` and `restricted` handling prevents public classification and + processing metadata; +- `restricted` data cannot be exposed through collection listing. + +A classification label does not invent purpose, lawful basis, consent, or row +authority. Those remain explicit reviewed access and processing fields. A +classification change can reduce availability or trigger review, but cannot +create a route or grant a token scope. + +## Use DPV as a governance projection + +The [Data Privacy Vocabulary 2.3](https://w3c-cg.github.io/dpv/2.3/dpv/) +can describe purposes, processing, parties, recipients, legal context, and +technical or organisational measures. Domain vocabularies describe what a +Registry fact means; DPV describes why and how an operation processes it. + +Relay can bind a reviewed DPV profile reference to a processing description. +DPV is not Relay's policy language. The runtime executes its small typed access +contract and never evaluates arbitrary RDF, DPV rules, ODRL, or remote content. +The current DPV document is a W3C Community Group report, so deployments pin +and review the chosen version rather than treating the vocabulary as an +unchanging authority. + +## Compile disclosure as a maximum + +Every operation names one reviewed disclosure profile. The profile's property +list is both the default and the maximum. A caller can request a non-empty +subset with `fields`, but cannot add a property, select a source column, change +a derivation, bypass a row boundary, or reduce the operation's authentication, +audit, quota, handling, or metadata controls. + +This is safe requester minimization, not dynamic attribute authorization. +Version 1 does not assign different maximum fields to two clients of the same +operation. Use separate named operations when two institutional purposes need +distinct reviewed representations, and keep the same Registry and Record +identifiers when both operations describe the same Record. + +## Publish semantics at the same visibility as the Record + +Every Record carries `schemaReference` and `semanticModelReference`. The +compiler refuses a configuration where the successful audience cannot resolve +safe projections of both references. The JSON-LD context is linked separately +because a context maps terms to IRIs but does not, by itself, define the full +semantic model. + +Metadata visibility is part of disclosure: + +- `public` artifacts can be retrieved anonymously; +- `operation-bound` artifacts require the same static gate as their operation; +- `operator-only` artifacts remain inside the sealed package and are never + mounted as HTTP content. + +The full source schema, complete SHACL shape, authored mapping files, and +classification inventory can remain operator-only while each successful +caller receives the safe operation-specific artifacts needed to interpret its +Record. + +These controls contribute technical evidence for privacy by design, +transparency, protection during use, and change-impact review. They do not +create lawful basis, institutional accountability, remedy, independent +oversight, or certification. Those responsibilities remain with the Registry +Authority and its governance environment. + +Continue with [Author a Registry Relay project](../../configure/relay/) to +apply this model, or [understand governed Registry publication](../governed-registry-publication/) +for the wider product boundary. diff --git a/docs/site/src/content/docs/index.mdx b/docs/site/src/content/docs/index.mdx index ba7432002..74f3b10c0 100644 --- a/docs/site/src/content/docs/index.mdx +++ b/docs/site/src/content/docs/index.mdx @@ -1,21 +1,19 @@ --- title: Registry Stack documentation -description: Answer a bounded question with Evidence Gateway, start a registry from a spreadsheet, or connect an existing registry. +description: Answer a bounded question with Evidence Gateway or publish a governed read-only Registry from SQLite with Registry Relay. status: current owner: registry-docs source_repos: - registry-stack - - registry-relay -last_reviewed: "2026-08-03" +last_reviewed: "2026-08-10" doc_type: explanation locale: en standards_referenced: [] --- -Registry Stack helps an institution answer questions about data it already -holds without giving callers direct access to the source. Two doors: Evidence Gateway -signs the answer to one bounded question, and Registry Relay exposes selected -records through a protected read-only API. +Registry Stack gives an institution two distinct ways to use data it already +holds. Evidence Gateway signs the answer to one bounded question. Registry +Relay publishes governed Registry resources through a controlled read-only API. ## Answer a bounded question with Evidence Gateway @@ -34,49 +32,32 @@ boundary in one verified request. When local authoring is complete, reviewed candidate without promoting local development state. Registry Mint remains an optional token issuer for deployments without a suitable identity provider. -## Start a registry from a spreadsheet +## Publish a governed Registry -Use the maintained spreadsheet path when the institution has a workbook or -can prepare a reviewed workbook derivative. -The first run starts Registry Relay over the shipped synthetic workbook. It -records a live authorization denial and a selected-field response; the offline -derived fixture supplies the zero-source-access evidence that a local file -cannot count itself. +Use Registry Relay when an authorized caller needs selected Records rather +than a signed answer. Relay binds one institution-owned Registry contract to +reviewed SQLite views, then compiles the API, disclosure rules, semantic +artifacts, access decisions, source provenance, and audit behavior together. -[Start a registry from a spreadsheet](tutorials/publish-spreadsheet-secured-registry-api/) -takes about 20 minutes and uses released artifacts without a source checkout. -After it works, [use your own spreadsheet](tutorials/use-your-spreadsheet/). - -## Connect an existing registry - -Use the HTTP path when the institution already operates a registry API. -Start with a fixed bounded request and synthetic observations, then bind the -reviewed source endpoint and its credentials. - -[Connect an existing HTTP registry](tutorials/author-registry-project/) covers -the base integration. -Continue with [OAuth client credentials](configure/oauth-client-credentials/) -and a [reviewed Rhai adapter](tutorials/configure-project-script-adapter/) when -the source requires authentication or response normalization. +[Publish a governed SQLite registry](tutorials/publish-governed-sqlite-registry/) +runs one synthetic business Registry from source through a sealed deployment +package and a real HTTP request. Continue with +[Relay project authoring](configure/relay/) for an institution-owned source and +[Relay operations](operate/relay/) for authentication, audit, and source-profile +choices. ## Keep the product boundaries clear -Registry Relay owns source access and protected record surfaces. +Registry Relay owns governed read-only Registry resources. Evidence Gateway owns bounded question answering, signing, and minimum disclosure, and -runs independently of Relay against its own configured authoritative HTTP sources. -The caller receives only the output authorized for that service. - -Both registryctl paths, spreadsheet and HTTP, use the same authoring, offline -test, disposable development, and build commands. The 1.0 project-local -workbook path stops there. A governed deployment starts after an -operator-managed HTTP source is bound, then continues through independent -approval and package generation. Evidence Gateway is not one of those paths: Evidence Gateway -has its own toolset and deployment project shape, covered by the -[Evidence Gateway overview](start/evidence-quickstart/). +runs independently against its own configured authoritative source. Registry +Mint remains an optional token issuer, not a Relay runtime dependency. A future +Evidence deployment can use a fixed Relay lookup as an ordinary HTTP source +without moving signing into Relay. ## Move beyond the first run -- [Choose a source path](configure/) +- [Understand Registry Relay](explanation/governed-registry-publication/) - [Prepare an operator handoff](operate/) - [Review the architecture](explanation/architecture/) - [Review security boundaries](security/) diff --git a/docs/site/src/content/docs/operate/relay.mdx b/docs/site/src/content/docs/operate/relay.mdx new file mode 100644 index 000000000..0fd2d1e69 --- /dev/null +++ b/docs/site/src/content/docs/operate/relay.mdx @@ -0,0 +1,216 @@ +--- +title: Operate Registry Relay +description: Bind a sealed Registry package to SQLite, authentication, audit, limits, and a private listener without weakening the governed contract. +status: draft +owner: registry-docs +source_repos: + - registry-stack +last_reviewed: "2026-08-10" +doc_type: how-to +locale: en +standards_referenced: [] +--- + +Registry Relay starts from one sealed package and one local runtime file. The +package owns the Registry contract and every generated artifact. The runtime +binds deployment paths, a listener, SQLite sources, an optional token issuer, +audit storage, integrity secrets, quotas, and time limits. Startup verifies the +complete closure before binding the listener. + +## Prepare the deployment layout + +Use separate locations for trusted configuration, read-only source data, +secrets, and writable audit state: + +```text +/etc/relay/business/ + runtime.yaml + package/ + audit-integrity-key + cursor-integrity-key +/srv/registries/ + business.sqlite +/var/lib/relay/business/ + audit.jsonl +``` + +Relay's runtime and package trust checks depend on Unix ownership, modes, +no-follow opens, and file identity. Non-Unix targets fail closed. Every path +component must be owned by root or the Relay service identity. Group-writable +and world-writable ancestors are refused, except for a root-owned sticky +shared ancestor. Symlinks are refused. + +Keep the runtime file, package, and snapshot source non-writable to the Relay +identity. Keep secret files owner-only and the audit directory writable only +by the Relay identity. Do not place the source database or audit file inside +the sealed package. + +## Bind one package and its sources + +The runtime document is intentionally smaller than the Registry contract: + +```yaml +apiVersion: relay.registrystack.org/v2alpha1 +kind: RelayRuntime +server: + bind: 127.0.0.1:8080 +packagePath: /etc/relay/business/package +sources: + companies: + path: /srv/registries/business.sqlite +authentication: + issuer: null +audit: + sink: /var/lib/relay/business/audit.jsonl + integrityKeyRef: secret:file/audit-integrity-key +cursor: + integrityKeyRef: secret:file/cursor-integrity-key + maximumAgeSeconds: 300 +limits: + requestTimeoutMilliseconds: 1500 + concurrentQueries: 32 +quotas: + requestsPerMinute: 10000 + burst: 1000 +shutdown: + gracePeriodMilliseconds: 1000 +``` + +Relative secret-file references resolve beneath the runtime directory. An +environment reference has the form `secret:env/NAME`. Relay never accepts a +secret value in the contract or runtime YAML. + +The runtime cannot change Registry identity, resources, source views, +properties, operations, disclosure profiles, access rules, classifications, +semantics, or metadata visibility. A runtime source identifier must match the +compiled package exactly. + +## Choose snapshot or live read-only SQLite + +Both source profiles open SQLite through the shared read-only executor. The +profile changes the provenance and consistency promise, not the API's +read-only boundary. + +| Property | Snapshot | Live read-only | +| --- | --- | --- | +| Publisher updates during service life | No | Yes, through a separate trusted process | +| File identity and content digest | Captured and enforced | Current path and handle identity enforced | +| Sidecars | Unsafe sidecars refused | SQLite-managed live state permitted under the live contract | +| Per-request consistency | Immutable file | One read transaction | +| List and cursor | Supported | Not supported in Version 1 | +| ETag and cache revalidation | Supported for cacheable public responses | Disabled | +| Source revision | Exact digest | Explicitly unversioned | + +Use snapshot for published extracts and reproducible public registries. Use +live read-only when another trusted process must publish compatible updates +without replacing the Relay process. Live resources support read and named +exact lookup only, return `Cache-Control: no-store`, and carry no historical +reproducibility claim. + +Both profiles pin the expected SQLite schema fingerprint. Relay verifies the +fingerprint inside the same transaction as a live read and refuses drift. A +source replacement, moved SQLite handle, unsafe snapshot sidecar, write +attempt, or incompatible schema fails closed +(`crates/registry-platform-sqlite/`). + +## Configure protected operations + +Public-only registries can set `authentication.issuer: null`. A package with a +protected operation requires one issuer in the runtime. Relay fetches the +issuer's OpenID Connect discovery document and keys during startup, then +verifies a narrow JWT access-token profile on every protected request. + +The verified token must carry one exact audience, an accepted token type and +algorithm, a trusted key identifier, bounded lifetime, issue and expiry times, +a token identifier, a principal, and the operation scope. Optional purpose and +row-binding authority come from verified scalar claims named by the compiled +access rule. Request headers cannot create that authority. + +Registry Mint is one optional issuer for deployments without an existing +authorization server. Relay has no production dependency on Mint, no client +registry, and no token-issuance route. The issuer assigns scopes and claims; +the compiled package still defines the maximum operation set. + +## Treat audit as a release gate + +Every data operation, including anonymous public access, writes a durable +attempt before source access and a terminal event before response bytes are +released. A refusal is recorded before its problem response. An audit failure +blocks source access or discards held response bytes rather than releasing an +unaudited result. + +Audit events identify the Registry, resource, operation, processing +description, access-rule revision, purpose when present, row-boundary kind, +disclosure profile, selected property identifiers or digest, handling level, +contract revision, and source revision. Events do not contain tokens, +selectors, source values, response values, or raw subject identifiers +(`crates/registry-relay-v2/src/audit.rs`). + +Protect the integrity key and audit path as one retained trust boundary. Relay +does not expose the raw audit chain as an Access Transparency API. A shared +cache hit served before Relay is also outside Relay's audit coverage. + +## Start and probe the service + +Start one process from the exact runtime path: + +```sh +relay serve --runtime /etc/relay/business/runtime.yaml +``` + +Relay performs these steps before listening: + +1. Validate the runtime file path, owner, mode, size, and file identity. +2. Load and verify the sealed package and every governed-file digest. +3. Re-observe the SQLite schema and source profile. +4. Open the read-only executor with its limits. +5. Resolve the issuer, audit sink, cursor key, and other secrets. +6. Construct the immutable service and pass readiness. +7. Bind the configured listener. + +Probe liveness from the same network boundary: + +```sh +relay healthcheck --url http://127.0.0.1:8080/health +curl -fsS http://127.0.0.1:8080/ready +``` + +`/health` proves the process can answer. `/ready` is successful only after the +package, source, issuer when configured, audit sink, and service state are +ready. Keep the listener on loopback or a private address and terminate TLS in +an operator-controlled reverse proxy or ingress. + +## Use bounded operational logs + +Relay writes JSON lifecycle and request-outcome logs to standard error. Request +outcomes contain only a fixed method, route template, status, latency, and +trace identifier. They do not contain request paths, identifiers, query +values, headers, bodies, selectors, or principals. + +`RELAY_LOG` accepts only `off`, `error`, `warn`, `info`, `debug`, or `trace` for +Relay-owned targets. An arbitrary tracing directive is ignored, which prevents +a deployment value from enabling dependency logs that may carry URLs or +headers. Derive metrics externally from the fixed value-free dimensions. + +## Deploy a new revision + +Relay does not hot-reload or merge contracts. Use a complete replacement: + +1. Build and review a new project revision with `relayctl check`, `generate`, + `test`, and `diff`. +2. Create a new sealed package path. Packaging refuses an existing output. +3. Prepare the matching source and runtime bindings without modifying the + active package. +4. Start a candidate process and wait for readiness. +5. Shift traffic through the operator-controlled proxy. +6. Drain the previous process and send `SIGTERM`. +7. Retain the package revision, source revision, audit segment, and change + review according to institutional policy. + +Rollback means activating a previously reviewed complete package with its +compatible source and runtime bindings. Relay never falls back to another +interpretation after startup failure. + +Continue with [Author a Registry Relay project](../../configure/relay/) for +the governed inputs and [understand Relay's product boundary](../../explanation/governed-registry-publication/) +for the one-Registry trust model. diff --git a/docs/site/src/content/docs/start/quickstart.mdx b/docs/site/src/content/docs/start/quickstart.mdx index 92fec9724..0903e8353 100644 --- a/docs/site/src/content/docs/start/quickstart.mdx +++ b/docs/site/src/content/docs/start/quickstart.mdx @@ -1,22 +1,20 @@ --- -title: Start with Registry Stack 1.0 -description: Answer a bounded question with Evidence Gateway, or publish protected records with Registry Relay, then adapt the source or the definition. +title: Start with Registry Stack +description: Answer a bounded question with Evidence Gateway or publish governed Registry resources from SQLite with Registry Relay. status: current owner: registry-docs source_repos: - registry-stack - - registry-relay -last_reviewed: "2026-08-03" +last_reviewed: "2026-08-10" doc_type: explanation locale: en standards_referenced: [] --- -Pick the door that matches what your caller needs. To learn only a fact about -one subject, start with Evidence Gateway. To read specific records or fields, start -with Registry Relay over the maintained spreadsheet registry. Both first runs -use one terminal and synthetic data. Neither needs a source checkout, -production keys, or a deployment package. +Pick the door that matches what the caller needs. To learn only a fact about +one subject, start with Evidence Gateway. To read selected Registry Records, +start with Registry Relay over the maintained SQLite business Registry. Both +first runs use synthetic data and stay on the local machine. ## Answer a bounded question with Evidence Gateway @@ -28,39 +26,25 @@ verification, and audit boundaries. then connects a visible Python registry, sends a real request, and verifies the minimum answer before reading the answer. -## Start from a spreadsheet +## Publish a governed Registry -[Start a registry from a spreadsheet](../../tutorials/publish-spreadsheet-secured-registry-api/) -creates the released `spreadsheet` project, checks its source behavior offline, -and runs Relay locally over maintained synthetic records. +[Publish a governed SQLite registry](../../tutorials/publish-governed-sqlite-registry/) +builds `relay` and `relayctl`, compiles one synthetic business Registry, +generates its semantic and API artifacts, runs its fixtures, seals a package, +and reads selected properties through the real HTTP service. -Take this path when the institution has a workbook, or can prepare a reviewed -workbook derivative. -Continue with [your own spreadsheet](../../tutorials/use-your-spreadsheet/) -after the sample works. - -## Connect an existing registry - -[Connect an existing HTTP registry](../../tutorials/author-registry-project/) -creates the released `http` project and tests one bounded source request before -an institution-owned endpoint is introduced. - -Continue with: - -- [OAuth client credentials](../../configure/oauth-client-credentials/) when - the source requires a bearer token -- [OAuth-backed Rhai](../../tutorials/configure-project-script-adapter/) when a - reviewed mapping needs several bounded same-origin requests or response - normalization -- [The OpenCRVS Events API case study](../../tutorials/verify-opencrvs-claims/) - for a synthetic example of that generic integration path +Continue with [Relay project authoring](../../configure/relay/) when the sample +works. The guide covers reviewed SQLite views, Registry Core bindings, +Consultation operations, classification, semantics, disclosure, fixtures, and +change review. ## Keep the two doors separate An institution can operate Registry Relay and Evidence Gateway, but they are independent products with separate sources, authorization, configuration, and -audit boundaries. Evidence Gateway does not use Relay as its source path. +audit boundaries. Registry Mint can issue Relay access tokens when no suitable +authorization server exists, but Relay does not depend on Mint at runtime. Choose the tutorial for the result you need. Start with the Evidence Gateway -tutorial for a signed, minimum-disclosure answer, or use the spreadsheet and -HTTP tutorials for a protected record API. +tutorial for a signed, minimum-disclosure answer, or use the Relay tutorial for +a governed read-only Registry API. diff --git a/docs/site/src/content/docs/start/when-to-use.mdx b/docs/site/src/content/docs/start/when-to-use.mdx index b887fd7a5..b8d4f3889 100644 --- a/docs/site/src/content/docs/start/when-to-use.mdx +++ b/docs/site/src/content/docs/start/when-to-use.mdx @@ -5,7 +5,7 @@ status: current owner: registry-docs source_repos: - registry-stack -last_reviewed: "2026-08-03" +last_reviewed: "2026-08-10" doc_type: explanation locale: en standards_referenced: [] @@ -77,8 +77,8 @@ approval. ## Next -- [Start a registry from a spreadsheet](../../tutorials/publish-spreadsheet-secured-registry-api/) -- [Connect an existing HTTP registry](../../tutorials/author-registry-project/) +- [Publish a governed SQLite registry](../../tutorials/publish-governed-sqlite-registry/) +- [Author a Registry Relay project](../../configure/relay/) - [Evaluate Evidence Gateway](../evaluate-evidence/) - [Read the architecture overview](../../explanation/architecture/) - [Review the security boundaries](../../security/) diff --git a/docs/site/src/content/docs/tutorials/publish-governed-sqlite-registry.mdx b/docs/site/src/content/docs/tutorials/publish-governed-sqlite-registry.mdx new file mode 100644 index 000000000..655ae1cfb --- /dev/null +++ b/docs/site/src/content/docs/tutorials/publish-governed-sqlite-registry.mdx @@ -0,0 +1,241 @@ +--- +title: Publish a governed SQLite registry +description: Build Relay and relayctl, prove one synthetic business Registry, and read a minimized Record through the real HTTP service. +status: draft +owner: registry-docs +source_repos: + - registry-stack +last_reviewed: "2026-08-10" +doc_type: tutorial +persona: + - data publisher + - operator +locale: en +standards_referenced: + - openapi + - json-schema + - json-ld + - shacl +--- + +import QuickstartMeta from '../../../components/QuickstartMeta.astro'; + +Registry Relay turns a reviewed SQLite view and one Registry contract into a +read-only API, semantic artifacts, and auditable runtime behavior. In this +tutorial, you will build the Relay V2 candidate, prove the maintained synthetic +business Registry, start the real service, and request two selected properties +without exposing the SQLite table or column names. + + + +The project contains synthetic organisations and reserved `.invalid` service +names. Do not substitute production data, identifiers, or keys during this +first run. + +## Build the candidate tools + +From the Registry Stack repository root, build `relayctl` and `relay` from the +same source revision: + +```sh +repo_root="$(pwd -P)" +cargo build --locked -p registry-relayctl -p registry-relay-v2 +export PATH="$repo_root/target/debug:$PATH" +relayctl --version +relay --version +``` + +`relayctl` owns the adopter workflow. `relay` owns startup verification and the +HTTP service. Both use the same compiler and runtime library. + +## Copy the maintained Registry + +Copy the business acceptance project into a disposable reader directory, then +materialize its SQLite snapshot from tracked SQL: + +```sh +reader_root="$(mktemp -d "${TMPDIR:-/tmp}/relay-tutorial.XXXXXX")" +cp -R products/relay-v2/acceptance/business-registry "$reader_root/business-registry" +cd "$reader_root/business-registry" +project="$(pwd -P)" +mkdir -m 700 var +python3 - "$project" <<'PY' +import sqlite3 +import sys +from pathlib import Path + +project = Path(sys.argv[1]) +database = project / "fixture.sqlite" +with sqlite3.connect(database) as connection: + connection.executescript((project / "fixture.sql").read_text()) +database.chmod(0o444) +PY +``` + +The authored project has four kinds of input: + +```text +registry.yaml governed Registry, resources, operations, and disclosure +runtime.yaml local package, source, audit, secret, and listener bindings +fixture.sql synthetic source construction +expected-http.yaml executable HTTP journey +codelists/ reviewed controlled values +governance/ legal-basis and identifier-lifecycle material +semantics/ optional external vocabulary mappings +``` + +The SQLite database is an input to the deployment. The database is not copied +into the sealed package. + +## Prove the project + +Compile the production profile before generating or serving anything: + +```sh +relayctl check "$project" --production +``` + +The report has `"status": "success"`, an empty `diagnostics` array, and a +contract revision. Production mode refuses unreviewed semantic or +classification suggestions, missing source bindings, schema drift, and +inconsistent disclosure or access rules. + +Generate the public and operator artifacts, then run the complete synthetic +journey through the real Relay router: + +```sh +relayctl generate "$project" +relayctl test "$project" +``` + +The generated directory includes OpenAPI 3.1, JSON Schema, SHACL, JSON-LD +contexts and vocabularies, codelist schemas, classification and processing +descriptions, and capability discovery. The fixture report covers successful +reads, pagination, filters, field minimization, JSON-LD equivalence, cache +revalidation, and safe refusals. + +## Seal the deployment package + +Package the governed contract and generated artifacts: + +```sh +relayctl package "$project" --output "$project/package" +``` + +The package report records the contract revision, package revision, source +schema fingerprint, governed-file digests, generated artifacts, media types, +and visibility. `relay` verifies that inventory before opening a source, +issuer, audit sink, or listener. + +## Start Relay + +Create disposable local integrity keys without printing their values, then +start the service: + +```sh +export RELAY_TEST_AUDIT_KEY="$(python3 -c 'import secrets; print(secrets.token_hex(32))')" +export RELAY_TEST_CURSOR_KEY="$(python3 -c 'import secrets; print(secrets.token_hex(32))')" +relay serve --runtime "$project/runtime.yaml" +``` + +Leave this terminal running. Startup logs end with a loopback listener at +`127.0.0.1:18082`. The business Registry is intentionally public, so this +synthetic first run has no token issuer. Protected registries use the same +runtime with one configured issuer. + +## Read the Registry metadata + +In a second terminal, read the service document: + +```sh +curl -fsS http://127.0.0.1:18082/v2 | python3 -m json.tool +``` + +The document identifies one Registry, its Authority and operator, and two +compiled Consultation capabilities: + +```json +{ + "registryIdentifier": "urn:example:registry:registered-businesses", + "name": "Synthetic registered business Registry", + "capabilities": [ + {"operationIdentifier": "registered-business.list", "pattern": "list"}, + {"operationIdentifier": "registered-business.read", "pattern": "retrieve"} + ] +} +``` + +The displayed document is abridged. Relay derives the capability inventory +from compiled operations rather than accepting a second authored list. + +## Request fewer properties + +Request active registrations and narrow `domainData` to two governed +properties: + +```sh +curl -fsS \ + 'http://127.0.0.1:18082/v2/resources/registered-business/records?status=ACTIVE&fields=registrationNumber,legalName&pageSize=4' \ + | python3 -m json.tool +``` + +Each item retains its Registry Core context while `domainData` contains only +the requested subset: + +```json +{ + "items": [ + { + "registryIdentifier": "urn:example:registry:registered-businesses", + "recordIdentifier": "BIZ-SYNTH-0001", + "revisionIdentifier": "7", + "lifecycleState": "ACTIVE", + "authorityIdentifier": "urn:example:institution:company-registrar", + "recordedAt": "2026-06-01T08:00:00Z", + "domainData": { + "registrationNumber": "BIZ-SYNTH-0001", + "legalName": "Example Orchard Cooperative" + } + } + ], + "pageInfo": {"nextCursor": null} +} +``` + +The displayed response is abridged. The real item also carries resolvable +schema and semantic-model references, and the response metadata records the +operation, disclosure profile, selected fields, contract revision, and source +revision. A request for `legal_name`, an unknown field, arbitrary sorting, or +an undeclared filter receives a bounded problem instead of widening the query. + +## Stop the service + +Return to the Relay terminal and press `Ctrl+C`. Relay completes a graceful +shutdown. Remove the disposable reader directory when you no longer need the +generated package or audit file, then clear the two local environment values. + +## What you proved + +- SQLite tables and columns did not become routes by convention. +- One reviewed contract produced the API, semantic artifacts, disclosure + rules, and capability inventory. +- The caller selected fewer properties but could not select more. +- Registry Core context remained present for every Record. +- The deployment package was verified before Relay opened its source or + listener. + +## Next + +- [Author a Registry Relay project](../../configure/relay/) to bind an + institution-owned SQLite view. +- [Operate Registry Relay](../../operate/relay/) to choose a source profile, + issuer, audit path, limits, and deployment permissions. +- [Understand Relay's product boundary](../../explanation/governed-registry-publication/) + before introducing additional resources. +- [Review semantics, classification, and disclosure](../../explanation/relay-semantics-and-disclosure/) + before publishing real fields. From 584bdd0190abe941efbe0902c7a12ee1b0a28fbf Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 10 Aug 2026 10:15:33 +0700 Subject: [PATCH 03/24] docs(relay): write Relay V2 guides for adopters Signed-off-by: Jeremi Joslin --- docs/site/astro.config.mjs | 2 +- .../scripts/information-architecture.test.mjs | 2 +- .../site/src/content/docs/configure/relay.mdx | 466 ++++++++++++------ .../governed-registry-publication.mdx | 218 ++++---- .../relay-semantics-and-disclosure.mdx | 246 +++++---- docs/site/src/content/docs/operate/relay.mdx | 339 ++++++++----- .../publish-governed-sqlite-registry.mdx | 271 +++++++--- 7 files changed, 951 insertions(+), 593 deletions(-) diff --git a/docs/site/astro.config.mjs b/docs/site/astro.config.mjs index de91b991c..35a59d079 100644 --- a/docs/site/astro.config.mjs +++ b/docs/site/astro.config.mjs @@ -362,9 +362,9 @@ export default defineConfig({ items: [ { label: 'Overview', slug: 'explanation/governed-registry-publication' }, { label: 'Publish a SQLite registry', slug: 'tutorials/publish-governed-sqlite-registry' }, + { label: 'Semantics and disclosure', slug: 'explanation/relay-semantics-and-disclosure' }, { label: 'Author a Relay project', slug: 'configure/relay' }, { label: 'Operate Relay', slug: 'operate/relay' }, - { label: 'Semantics and disclosure', slug: 'explanation/relay-semantics-and-disclosure' }, ], }, { diff --git a/docs/site/scripts/information-architecture.test.mjs b/docs/site/scripts/information-architecture.test.mjs index 34cb803c1..375a58b9a 100644 --- a/docs/site/scripts/information-architecture.test.mjs +++ b/docs/site/scripts/information-architecture.test.mjs @@ -87,9 +87,9 @@ test('keeps the compact Relay V2 reader journey under existing registries', () = [ "slug: 'explanation/governed-registry-publication'", "slug: 'tutorials/publish-governed-sqlite-registry'", + "slug: 'explanation/relay-semantics-and-disclosure'", "slug: 'configure/relay'", "slug: 'operate/relay'", - "slug: 'explanation/relay-semantics-and-disclosure'", ], 'Relay V2 reader journey', ); diff --git a/docs/site/src/content/docs/configure/relay.mdx b/docs/site/src/content/docs/configure/relay.mdx index 07c32b57d..1c2d732ed 100644 --- a/docs/site/src/content/docs/configure/relay.mdx +++ b/docs/site/src/content/docs/configure/relay.mdx @@ -1,6 +1,6 @@ --- title: Author a Registry Relay project -description: Bind one Registry contract to reviewed SQLite views, operations, semantics, classifications, fixtures, and a deployment runtime. +description: Turn a reviewed SQLite view into a checked Registry contract, synthetic test journey, and sealed Relay package. status: draft owner: registry-docs source_repos: @@ -16,66 +16,115 @@ standards_referenced: - govstack-digital-registries --- -A Registry Relay project describes one authoritative Registry and binds its -governed resources to SQLite views. The contract owns meaning, disclosure, -operations, access rules, and metadata visibility. The runtime owns local -paths, the listener, one optional token issuer, audit storage, secrets, and -process limits. `relayctl check` compiles both without allowing runtime values -to override Registry policy. +Use `relayctl` to describe one institution-owned Registry, bind it to reviewed +SQLite views, and produce a sealed package for Registry Relay. This guide is +for the data publisher and technical implementer who can review the source +schema, public meaning, access rules, and permitted disclosure together. -## Use the authoring lifecycle +## When to use this -Relay has one compact lifecycle: +Use this guide after the [synthetic Relay tutorial](../../tutorials/publish-governed-sqlite-registry/) +works and the institution has selected an authoritative SQLite source. The +result is a candidate package for operator review, not a running service. -```text -init -> inspect -> check -> generate -> test -> diff -> package +Relay fits when callers need selected read-only Registry Records. Relay does +not turn every table into an endpoint. The project must name each resource, +operation, filter, public property, access rule, and semantic meaning that the +institution intends to publish. + +## Before you start + +Prepare these inputs with the Registry Authority, the institution accountable +for the Registry: + +- One SQLite database or a non-writable copy for structural inspection. +- One or more narrow SQLite views that exclude internal columns and expose + stable record identifiers, revisions, lifecycle states, and recorded times. +- The Registry's stable identifier, Authority, scope, and identifier lifecycle + policy. +- The callers, purposes, fields, and filters each operation is allowed to use. +- Synthetic Records that exercise the same shapes and controlled values as the + real source without copying production data. +- `relayctl` on `PATH`. + +Read [semantics, classification, and disclosure](../../explanation/relay-semantics-and-disclosure/) +before assigning public property names or handling levels. + +## Initialize the project + +Create the contract and governance starter files: + +```sh +relayctl init ./business-registry ``` -| Command | Purpose | -| --- | --- | -| `relayctl init ` | Create a complete neutral project with visibly unreviewed starters. | -| `relayctl inspect ` | Read SQLite structure without sampling row values. | -| `relayctl check ` | Compile and validate the contract, runtime, source schema, and governed files. | -| `relayctl generate ` | Reproduce API, semantic, governance, and validation artifacts. | -| `relayctl test ` | Run synthetic HTTP journeys through the shared Relay kernel. | -| `relayctl diff ` | Classify meaning, disclosure, access, source, and semantic changes. | -| `relayctl package --output ` | Create a sealed, deterministic deployment package. | +The report lists the files it created: + +```json +{ + "status": "success", + "diagnostics": [], + "details": { + "kind": "initialized", + "files": [ + "registry.yaml", + "runtime.yaml", + "governance/identifier-lifecycle.yaml", + "governance/classification-review.yaml", + "governance/legal-basis.yaml", + "governance/processing.dpv.yaml", + "codelists/record-lifecycle.yaml" + ] + } +} +``` -Use `--json` when CI needs the shared typed report. Exit `1` means the project -was refused, `2` means the invocation was invalid, and `3` means an operational -failure prevented the command from completing -(`crates/registry-relayctl/src/lib.rs`). +The generated values are prompts for review, not accepted policy. Production +checks refuse the project until you replace or approve every suggestion. -## Start from structure, not row values +## Inspect the SQLite structure -Inspect the source database before writing public names: +Ask the source owner for a consistent, non-writable inspection copy of the +database. Do not copy a database while another process is writing it. Then ask +`relayctl` to record its structure without sampling row values: ```sh -relayctl inspect /srv/registry/business.sqlite \ +chmod a-w ./business-inspection.sqlite +relayctl inspect ./business-inspection.sqlite \ --starters ./business-registry/inspection ``` -Inspection reports tables, views, columns, declared SQLite types, nullability, -key membership, and the schema fingerprint. Inspection never reads row values. -Generated property, semantic, and classification material remains unreviewed -until an author accepts or replaces it. +The report includes tables, views, columns, declared SQLite types, nullability, +key membership, and one schema fingerprint. The final detail identifies the +generated starter: + +```json +{ + "status": "success", + "diagnostics": [], + "details": { + "kind": "schema-inspection", + "fingerprint": "sha256:", + "starter_file": "schema-starter.yaml" + } +} +``` + +Use `inspection/schema-starter.yaml` as a review aid. Copy the accepted +fingerprint, view names, and column bindings into `registry.yaml`; do not treat +the starter as a classification or publication decision. -Create narrow views in the source database before binding a resource. A view -is the source disclosure boundary: exclude internal columns, normalize codes, -and expose stable record context there. Relay does not accept caller-selected -tables, joins, expressions, columns, or ordering. +Create or revise the source views before continuing. A view is the database +boundary presented to Relay. It can exclude internal fields and normalize +codes before the Registry contract assigns public names. Callers cannot choose +tables, joins, expressions, source columns, or ordering at request time. -## Describe one Registry +## Identify the Registry -The top of `registry.yaml` identifies the institution-owned Registry: +Edit the generated `registry.yaml`. Replace the starter Registry identity with +institutional values: ```yaml -apiVersion: relay.registrystack.org/v2alpha1 -kind: RegistryContract -metadata: - id: registered-businesses - version: 2026-08-01 - title: Registered businesses registry: registryIdentifier: urn:example:registry:registered-businesses name: Registered business Registry @@ -90,24 +139,21 @@ registry: identifierLifecyclePolicyRef: governance/identifier-lifecycle.yaml ``` -One process serves one Registry. Related resources can share that Registry -when they have the same Authority and authoritative scope. A resource is a -governed Record type within the Registry, not a SQLite table and not a second -Registry. - -Keep these roles distinct: +One Relay process serves one Registry. Related resources can share it when +they have the same Authority and authoritative scope. A resource is a governed +Record type within the Registry, not a SQLite table and not another Registry. -- The Registry Authority is accountable for the Registry in its declared - scope. -- The privacy controller determines processing responsibilities. -- The publisher authorizes publication. -- The operator runs the technical service. -- The audit owner controls retained access evidence. +Keep the institutional roles separate in the contract. The Registry Authority +is accountable for the Registry, the privacy controller determines processing +responsibilities, the publisher approves publication, the operator runs the +service, and the audit owner controls retained access evidence. -## Bind Registry Core and domain properties +## Bind one resource -Every resource binds one reviewed source view and four source-backed Registry -Core values: +Each returned Record includes mandatory Registry and Record context: Registry +identifier, stable Record identifier, revision, lifecycle state, Authority, +recorded time, response schema, and semantic model. Bind the four source-backed +values to the reviewed view: ```yaml resources: @@ -126,148 +172,246 @@ resources: recordedAt: {sourceColumn: recorded_at} ``` -Relay adds the Registry identifier, Authority identifier, schema reference, -and semantic-model reference from the contract. Callers cannot remove this -context with `fields`. `recordedAt` means the authoritative revision-recorded -time, not Relay startup, snapshot, or response time. +`recordedAt` is the time the Authority recorded that revision. It is not Relay +startup time, snapshot time, or response time. -Declare each public property separately from its source column: +Declare every published property separately from its SQLite column: ```yaml -properties: - legalName: - sourceColumn: legal_name - type: string - sourceRequired: true - semanticTerm: local:legalName - label: Legal name - description: Registered legal name of the organisation. - classification: - privacy: potentially-personal - institutional: public-by-law + properties: + legalName: + sourceColumn: legal_name + type: string + sourceRequired: true + semanticTerm: local:legalName + label: Legal name + description: Registered legal name of the organisation. + classification: + privacy: potentially-personal + institutional: public-by-law + handling: public + status: reviewed ``` -The property name is the stable public key. The source column remains an -operator detail. `sourceRequired` validates the complete source Record. The -generated response schema permits an authorized caller to omit selectable -domain properties while preserving Registry Core. +`legalName` is the stable API property. `legal_name` remains a local storage +detail. `sourceRequired` checks the complete source Record, while the response +schema still permits a caller to request fewer authorized domain properties. + +## Declare only the required operations -## Compile only needed operations +An operation can retrieve one Record by identifier, list a deterministic +collection, or perform a named exact lookup. Relay advertises those operations +as capabilities in the GovStack Consultation family. -A resource can declare identifier read, deterministic list, named exact -lookup, or an appropriate subset. Operations compile into Consultation -capabilities: +This public business Registry needs list and retrieve: ```yaml -disclosureProfiles: - public-register: - properties: - - registrationNumber - - legalName - - registrationStatus -operations: - list: - access: public - disclosureProfile: public-register - filters: - - name: status - property: registrationStatus - type: controlled-code - allowUnfiltered: true - orderBy: [registrationNumber] - pagination: - defaultPageSize: 25 - maximumPageSize: 100 - read: - access: public - disclosureProfile: public-register + disclosureProfiles: + public-register: + properties: [registrationNumber, legalName, registrationStatus] + operations: + list: + access: public + disclosureProfile: public-register + filters: + - name: status + property: registrationStatus + type: controlled-code + allowUnfiltered: true + orderBy: [registrationNumber] + pagination: + defaultPageSize: 25 + maximumPageSize: 100 + read: + access: public + disclosureProfile: public-register ``` -List filters are typed equality parameters with direct camelCase names. Relay -reserves `pageSize`, `cursor`, and `fields`. The caller cannot add an operator, -sort, join, source column, or expression. - -Use a named exact lookup for personal or sensitive selectors. The lookup -declares its complete bounded body, one scope, optional trusted purpose, an -optional verified-claim row boundary, and a maximum of one result. Lookup-only -resources compile no collection route and no identifier-read route. +List filters are named, typed equality parameters. Relay reserves `pageSize`, +`cursor`, and `fields`. A caller cannot add an operator, sort, join, source +column, or expression. -## Classify every reviewed column +Use a named exact lookup for sensitive selectors. The lookup defines its +complete bounded request body, required scope, optional trusted purpose, +optional verified-claim row boundary, and maximum of one result. A lookup-only +resource publishes neither a collection route nor an identifier-read route. -Classification belongs to the published property. Record-context, selector, -row-binding, filter, and ordering columns that are not properties need a -`sourceColumnClassifications` entry. Resource defaults reduce repetition, and -the compiler expands them into an effective classification for every reviewed -column. +## Complete classification and semantics -The initial handling order is `public`, `internal`, `confidential`, and -`restricted`. A more restrictive property or hidden column can narrow an -operation but cannot widen one. `restricted` data cannot appear in a list. -Production checks refuse unclassified published properties and unclassified -reviewed columns. +Classify every published property and every hidden column used for identifiers, +revision, lifecycle, selectors, row boundaries, filtering, or ordering. The +technical handling order is `public`, `internal`, `confidential`, and +`restricted`. More restrictive data can narrow an operation but cannot widen +one, and `restricted` data cannot appear in a list. -## Make semantics useful from the first contract - -Set a stable local vocabulary base and give every resource and property a -local semantic identifier: +Set one stable local vocabulary base in `registry.yaml`: ```yaml semantics: localVocabulary: https://business.example.invalid/vocabulary/ ``` -Relay generates the local vocabulary, JSON-LD context, JSON Schema, SHACL -shape, and codelist schemas from the compiled contract. External mappings are -optional governed files. Each mapping pins its profile, version, digest, and -relation strength. A generated local term never claims automatic equivalence -with SEMIC, PublicSchema, schema.org, or another vocabulary. +Relay generates a local vocabulary, JSON-LD context, JSON Schema, SHACL shape, +and codelist schemas from the reviewed contract. Mappings to the European +Commission Semantic Interoperability Community (SEMIC), PublicSchema, +schema.org, or another external vocabulary are optional governed files. Relay +does not infer equivalence between local and external terms. + +Bind each operation to its reviewed processing purpose, recipient class, legal +basis reference, and safeguards. Classify service, resource, semantic, +classification, and processing metadata as `public`, `operation-bound`, or +`operator-only`. A caller who receives a Record must also be able to retrieve +safe versions of the schema and semantic model linked from that Record. -## Describe processing and metadata visibility +## Add synthetic HTTP journeys -Bind processing intent to the operations it governs: +Create `fixture.sql` with synthetic Records that cover valid, invalid, absent, +and boundary cases. Create `expected-http.yaml` with the requests and exact +responses the project must preserve. Do not copy production Records, tokens, or +identifiers into either file. + +For one public identifier-read operation, begin with this minimal journey and +add cases for the rest of the contract: ```yaml -processingDescriptions: - - id: statutory-publication - operationRefs: [list, read] - purpose: statutory-publication - recipientClass: public - legalBasisRef: governance/legal-basis.yaml - dpvProfileRef: governance/legal-basis.yaml - safeguards: - - property-minimization - - deterministic-pagination - - change-impact-review +schemaVersion: relay.registrystack.org/http-journey/v1alpha1 +registry: urn:example:registry:registered-businesses +authorizations: {} +steps: + - id: identifier-read + request: + method: GET + path: /v2/resources/registered-business/records/BIZ-SYNTH-0001 + expect: + status: 200 + recordIdentifier: BIZ-SYNTH-0001 ``` -`metadataVisibility` independently classifies service, resource, semantic, -classification, and processing metadata as `public`, `operation-bound`, or -`operator-only`. A Record audience must be able to retrieve safe projections -of the exact schema and semantic model referenced by that Record. A public -resource cannot make a protected sibling's artifacts public. +`fixture.sql` must create the reviewed view named in `registry.yaml` and insert +a synthetic `BIZ-SYNTH-0001` Record with every required source value. Add at +least one invalid row to prove source-shape refusal and one unresolved request +that does not reveal whether a Record exists. + +The journey must cover every published operation, permitted filter, expected +field subset, protected access rule, and safe refusal that matters to the +Registry. `relayctl test` runs these requests through the Relay HTTP router +against an isolated SQLite database built from `fixture.sql`. -## Validate and review a change +## Check and package the project -Run the focused loop after every contract or source-view change: +Run the production check: ```sh relayctl check ./business-registry --production +``` + +The report must identify `check`, production mode, and the accepted contract +revision: + +```text +relayctl check +{ + "status": "success", + "diagnostics": [], + "details": { + "kind": "check", + "production": true, + "contract_revision": "sha256:" + } +} +``` + +Generate the artifacts: + +```sh relayctl generate ./business-registry +``` + +The result identifies `generate`, the same contract revision, and an +`artifacts` inventory. Now run the synthetic journey: + +```sh relayctl test ./business-registry +``` + +The test result identifies `test`, the same contract revision, and one +`"passed": true` entry for every HTTP step. Any diagnostic or failed step +stops the handoff. + +Before changing an approved project, keep its previous revision in a separate +directory or worktree. Classify the proposed change: + +```sh relayctl diff ./approved-business-registry ./business-registry ``` -Review the diff when it reports a new property, weaker classification, broader -operation, removed row binding, expanded purpose or scope, changed view, or -semantic mapping change. Git and CI remain the approval workflow. Relay has no -administration UI or approval service. +Review any new property, weaker classification, broader operation, removed row +boundary, expanded purpose or scope, changed source view, or semantic mapping. +Git and CI provide the approval workflow. Relay does not include an +administration or approval service. + +Each change has a class, impact, location, and stable description. For example, +expanding a disclosure profile appears as: + +```json +{ + "class": "disclosure-expanded", + "impact": "widening", + "location": "resources.registered-business.operations.list.disclosureProfile", + "description": "the maximum disclosure property set expanded" +} +``` -Create the candidate only after the source schema, governed files, generated -artifacts, and fixtures pass review: +After approval, create a new output directory: ```sh -relayctl package ./business-registry --output ./candidate-package +relayctl package ./business-registry --output ./business-registry-package +``` + +The package result has this stable shape: + +```text +relayctl package +{ + "status": "success", + "diagnostics": [], + "details": { + "kind": "package", + "manifest": { + "packageRevision": "sha256:", + "contractRevision": "sha256:", + "sourceSchemaFingerprints": {"companies": "sha256:"} + } + } +} ``` -Continue with [Operate Registry Relay](../../operate/relay/) for the runtime, -source profiles, secret bindings, and startup ceremony. +The complete report also records governed-file digests, generated artifacts, +media types, and visibility. Give the package, matching source, and runtime +bindings to the operator as one revisioned deployment candidate. + +## Verify the handoff + +Confirm that these statements are true before deployment: + +- `relayctl check --production`, `generate`, and `test` all report success for + the same contract revision. +- The approved diff contains no unexplained disclosure, access, source, or + semantic change. +- The package directory did not exist before packaging and is non-writable + after the handoff. +- The operator has the matching SQLite source or approved live-source process, + but no production data is inside the package. + +Continue with [Operate Registry Relay](../../operate/relay/) to bind the package +to deployment paths, authentication, audit, limits, and a listener. + +## Troubleshooting + +| Symptom | Cause | Fix | +| --- | --- | --- | +| `relayctl inspect` refuses the database | The inspection copy is writable, unsafe, or reached through a symlink | Create a non-writable copy on a physical path and inspect that copy. | +| `check --production` reports suggested governance | A generated starter was not institutionally reviewed | Replace or approve the starter and record `status: reviewed` in the governed file. | +| The schema fingerprint changed | The SQLite structure no longer matches the contract | Review the database migration, update bindings intentionally, and rerun the complete change workflow. | +| A field cannot be added to `fields` | The operation's disclosure profile does not include that public property | Add the property only after classification, semantic, processing, and disclosure review. | +| Packaging refuses the output | The destination already exists | Choose a new revisioned directory. Packaging never overwrites an existing package. | diff --git a/docs/site/src/content/docs/explanation/governed-registry-publication.mdx b/docs/site/src/content/docs/explanation/governed-registry-publication.mdx index 392e5a698..1b78ea209 100644 --- a/docs/site/src/content/docs/explanation/governed-registry-publication.mdx +++ b/docs/site/src/content/docs/explanation/governed-registry-publication.mdx @@ -1,6 +1,6 @@ --- title: How Relay publishes a governed Registry -description: The Registry, resource, compiler, Consultation-family, and product boundaries that distinguish Relay from a REST wrapper over SQLite. +description: Understand how Registry identity, reviewed SQLite views, fixed read operations, disclosure, and runtime controls form one Relay API. status: draft owner: registry-docs source_repos: @@ -14,50 +14,45 @@ standards_referenced: - universal-dpi-safeguards --- -Registry Relay publishes one institution-owned Registry as a small set of -governed, semantically described, read-only resources. SQLite is the first -source adapter, not the product identity. The product is the compiled agreement -between Registry identity, meaning, disclosure, authorization, provenance, -documentation, and runtime behavior. - -The concise promise is: - -> Give Relay an authoritative SQLite database and a Registry contract. Relay -> produces a protected registry API whose meaning, disclosure, authorization, -> provenance, and documentation agree by construction. +Registry Relay is for an institution that needs to publish selected read-only +Registry Records without exposing its database as a general API. The +institution reviews one Registry contract that connects identity, meaning, +disclosure, authorization, provenance, documentation, and runtime behavior to +specific SQLite views. ## Start with the Registry, not the database -A Relay process serves exactly one Registry in one administrative trust -domain. The Registry has a stable identifier, name, Authority, optional -operator, authoritative scope, base URI, and declared standards-alignment -targets. +A Relay process serves one Registry in one administrative trust domain. A +Registry is an authoritative collection with a stable identifier, name, +accountable Registry Authority, optional technical operator, declared scope, +and base URI. -A resource is a governed Record type within that Registry. A resource is not a -table and not another Registry. One view can support several resources, and -several source tables can feed one reviewed view. Database objects without a -contract binding remain invisible. +A resource is one governed Record type inside that Registry. It is not a table +and not another Registry. One reviewed SQLite view can support several +resources, and several tables can feed one view. A database object without a +contract binding is not visible to Relay. -Every returned Record carries two layers: +Every returned Record has two parts: -- Registry Core context identifies the Registry, Record, revision, lifecycle - state, Authority, recorded time, schema, and semantic model. +- Mandatory record context identifies the Registry, Record, revision, + lifecycle state, Authority, recorded time, response schema, and semantic + model. The contract calls this Registry Core context. - `domainData` contains only the properties permitted by the operation's - disclosure profile and optional caller minimization. + disclosure profile and any smaller subset requested by the caller. -The pair `(registryIdentifier, recordIdentifier)` identifies a Record. A -JSON-LD `@id` can provide a global IRI, but does not replace either -authoritative identifier. +The pair `(registryIdentifier, recordIdentifier)` identifies a Record. A JSON +for Linked Data (JSON-LD) `@id` can add a global Internationalized Resource +Identifier, but does not replace either authoritative identifier. -## Compile one agreement +## Compile one reviewed agreement ```mermaid flowchart LR contract["Registry contract
identity · resources · access · disclosure"] sqlite[("Reviewed SQLite views")] compiler["Relay compiler"] - package["Sealed package
query plans · artifacts · revisions"] - runtime["Relay runtime
auth · audit · limits"] + package["Sealed package
queries · artifacts · revisions"] + runtime["Relay runtime
authentication · audit · limits"] api["Registry API
JSON · JSON-LD · discovery"] contract --> compiler @@ -68,114 +63,121 @@ flowchart LR runtime --> api ``` -The compiler resolves source bindings, validates the schema fingerprint, -expands classifications, fixes query plans, derives access and disclosure -plans, and generates OpenAPI and semantic artifacts. The sealed package binds -those outputs to governed-file digests and one contract revision. +The compiler resolves source bindings, validates the SQLite structure, +expands classifications, fixes the allowed queries, derives access and +disclosure plans, and generates OpenAPI and semantic artifacts. The sealed +package binds those results to the governed input digests and one contract +revision. -The runtime never reconstructs policy from request parameters. The caller can -choose only a compiled operation, declared equality filters, a bounded page -size and cursor, and fewer properties from the authorized disclosure profile. +At request time, callers can select only a compiled operation, declared +equality filters, a bounded page size and cursor, and fewer properties from the +authorized disclosure profile. They cannot introduce SQL, choose a table, +change ordering, or turn a private column into a public property. -## Expose only the needed Consultation patterns +## Offer only the required read capabilities -Relay uses API families as external capability and trust groupings. Families -are not internal crates, service names, or URL prefixes. Version 1 compiles -three Consultation patterns: +The GovStack Digital Registries specification groups Registry reads under the +Consultation API family. Relay advertises only the capabilities compiled for a +deployment: -| Authored operation | Advertised pattern | Boundary | +| Contract operation | Advertised capability | Result | | --- | --- | --- | | Identifier read | `consultation.retrieve` | One Record by its stable identifier. | | Deterministic list | `consultation.list` | A bounded collection with declared filters and ordering. | | Named exact lookup | `consultation.search` | One resolved Record or one indistinguishable unresolved outcome. | -Named exact lookup is not Record Match. Relay returns no candidate list, -confidence score, ranking, or matching explanation. A lookup-only sensitive -Registry compiles no list or identifier-read route, even when a token carries -broader scopes. +An exact lookup does not return candidates, confidence scores, rankings, or a +matching explanation. A lookup-only sensitive resource publishes no list or +identifier-read route, even when a token contains broader scopes. -The service document at `GET /v2` derives its visible capability inventory -from the compiled operations. A deployment advertises only the patterns it -implements. The current GovStack Digital Registries and API Design Guide drafts -are directional inputs. Generated material is alignment evidence, not a -conformance or certification claim -(`products/relay-v2/STANDARDS-ALIGNMENT.md`). +`GET /v2` identifies the Registry and lists the capabilities visible to the +caller. The service metadata names the GovStack Digital Registries and API +Design Guide versions used as alignment targets. This is alignment evidence, +not a conformance or certification claim. ## Keep access and disclosure separate -A protected request crosses distinct gates: +A protected request crosses six distinct gates: -1. Strict JWT verification establishes one principal, audience, issuer, - lifetime, token identifier, and scopes. -2. The compiled access rule requires one operation scope and can require a - trusted purpose and an authority-to-row binding. -3. Relay builds a fixed parameterized query over the reviewed view. +1. JSON Web Token verification establishes one principal, audience, issuer, + lifetime, token identifier, and set of scopes. +2. The access rule requires the operation scope and can also require a trusted + purpose or an authority-to-row claim. +3. Relay builds the fixed parameterized query for the reviewed view. 4. The selected source Record passes complete source-shape validation. 5. The disclosure plan emits the operation's maximum property set or a caller-requested subset. -6. Durable terminal audit succeeds before the held response bytes are - released. +6. Durable terminal audit succeeds before Relay releases the held response. -Purpose and row authority come from verified claims named by the contract. -Caller headers and query parameters cannot create authority. Different -operations can have different disclosure profiles. Version 1 does not provide -dynamic per-client property permissions within one operation. +Purpose and row authority come from verified token claims named by the +contract. Request headers and query parameters cannot create authority. +Different operations can expose different reviewed property sets. Within one +operation, two clients share the same maximum property set; a caller can only +request less. -## Make public and protected metadata follow the same boundary +## Protect metadata with the same boundary Registry identity is public. Resource, schema, semantic, classification, and processing artifacts can be public, operation-bound, or operator-only. -Operation-bound artifacts use the same access gate as the Record that links -them. A public sibling resource cannot reveal a protected resource's existence -or artifacts. +Operation-bound artifacts require the same static access rule as the Record +that links to them. A public sibling resource does not reveal a protected +resource's existence or artifacts. -Relay publishes a safe public OpenAPI projection and retains the full OpenAPI -document in the sealed package. The public document omits protected selector -shapes and operator-only metadata. Relay does not create caller-specific -OpenAPI at request time. +Relay publishes a safe public OpenAPI document and retains the full OpenAPI +document in the sealed package. The public document omits protected request +shapes and operator-only metadata. Relay does not generate caller-specific +OpenAPI documents at request time. -## Understand the source profiles +## Choose reproducibility or live publication Snapshot mode captures an immutable read-only SQLite file with stable identity, -digest, and reproducible source revision. Live read-only mode permits a -separate trusted publisher to update the database while Relay keeps one fixed -contract and one consistent transaction per request. +digest, and reproducible source revision. Live read-only mode allows a separate +trusted publisher to update a compatible database while Relay keeps one fixed +contract and one consistent read transaction per request. -Snapshot is stronger but optional. Version 1 live sources are unversioned, -support read and named lookup only, and return no ETag or cacheable response. -Both profiles deny writes, arbitrary SQL, undeclared functions, schema drift, -unbounded rows, and unbounded response values. +Snapshot is stronger but optional. Live sources are explicitly unversioned, +support identifier read and named exact lookup, and return no ETag or cacheable +response. Both profiles deny writes, arbitrary SQL, undeclared functions, +schema drift, unbounded rows, and unbounded response values. The +[operations guide](../../operate/relay/) compares the deployment tradeoffs. -## Keep Relay, Evidence, and Mint distinct +## Keep Relay, Evidence Gateway, and Mint distinct -Relay responses are unsigned. TLS protects transport, OAuth protects -controlled operations, and revisions plus tamper-evident audit support -accountability. +Relay responses are unsigned. Transport Layer Security (TLS) protects +transport, OAuth 2.0 protects controlled operations, and revisions plus +tamper-evident audit support accountability. -Evidence Gateway remains the product for a portable signed, -minimum-disclosure assertion. Evidence can later consume a Relay-protected -exact lookup as an ordinary fixed HTTP source without moving signing into -Relay. Registry Mint is an optional OAuth issuer when an institution lacks a -suitable authorization server. Relay has no production dependency on either -product. +Evidence Gateway is the separate product for portable signed, +minimum-disclosure assertions. Registry Mint is an optional OAuth issuer when +an institution lacks a suitable authorization server. Relay does not issue +assertions or tokens and has no production runtime dependency on either +component. ## Know the product boundary -Relay is a governed semantic Registry publisher and a protected read-only API -over existing authoritative data. Relay is not: - -- a generic SQLite REST generator or SQL proxy; -- a write API, registry administration service, or workflow engine; -- an RDF store, SPARQL endpoint, or runtime inference engine; -- a general policy engine, consent system, or identity provider; -- a matching, eligibility, case-management, aggregate, or analytics service; -- a credential issuer or signed-assertion service; -- a multi-Registry hosting layer. - -PostgreSQL, GeoJSON and SpatiaLite, additional API families, and richer -semantic profiles remain later extensions. Version 1 does not introduce a -generic storage abstraction before a second adapter proves the boundary. - -Continue with [Publish a governed SQLite registry](../../tutorials/publish-governed-sqlite-registry/) -for a complete first run, or [review semantics, classification, and disclosure](../relay-semantics-and-disclosure/) -for the metadata and minimization model. +Relay publishes governed, semantically described, read-only Registry Records +from SQLite. Relay is not: + +- A generic SQLite REST generator or SQL proxy. +- A write API, Registry administration service, or workflow engine. +- A Resource Description Framework (RDF) store, SPARQL endpoint, or runtime + inference engine. +- A general policy engine, consent system, or identity provider. +- A matching, eligibility, case-management, aggregate, or analytics service. +- A credential issuer or signed-assertion service. +- A multi-Registry hosting layer. + +SQLite is the supported source. PostgreSQL, GeoJSON and SpatiaLite, other API +families, historical retrieval, and runtime semantic inference are not +supported by this contract. + +## Next + +- [Publish a governed SQLite registry](../../tutorials/publish-governed-sqlite-registry/) + for a complete local run with synthetic data. +- [Review semantics, classification, and disclosure](../relay-semantics-and-disclosure/) + before naming and classifying real fields. +- [Author a Registry Relay project](../../configure/relay/) to bind an + institution-owned SQLite view. +- [Operate Registry Relay](../../operate/relay/) to deploy one reviewed + package. diff --git a/docs/site/src/content/docs/explanation/relay-semantics-and-disclosure.mdx b/docs/site/src/content/docs/explanation/relay-semantics-and-disclosure.mdx index f9a679283..04adf372b 100644 --- a/docs/site/src/content/docs/explanation/relay-semantics-and-disclosure.mdx +++ b/docs/site/src/content/docs/explanation/relay-semantics-and-disclosure.mdx @@ -1,6 +1,6 @@ --- title: Semantics, classification, and disclosure in Relay -description: How Relay creates useful local semantics, keeps external mappings optional, classifies reviewed columns, and compiles narrow representations. +description: Understand how Relay generates a local semantic model, classifies source columns, and limits each response to reviewed properties. status: draft owner: registry-docs source_repos: @@ -16,189 +16,187 @@ standards_referenced: - universal-dpi-safeguards --- -Relay treats semantics and classification as inputs to a safe public contract, -not as catalog decoration. An adopter can begin without an existing JSON-LD -context, SHACL shape, or vocabulary mapping. Relay generates a useful local -model from the reviewed Registry contract, while external alignments remain -optional, pinned, and explicit. +Registry Relay makes meaning and data handling part of the API contract. An +institution can start without an existing JSON for Linked Data (JSON-LD) +context, Shapes Constraint Language (SHACL) shape, or external vocabulary +mapping. Relay generates a local semantic model from the reviewed Registry +contract, while external alignments remain optional and explicit. ## Separate four layers of meaning -One SQLite column can participate in several distinct concerns: +One SQLite column can participate in four distinct concerns: | Layer | Question | Example | | --- | --- | --- | | Source binding | Where does the value come from? | `legal_name` in `relay_registered_businesses`. | | Domain meaning | What does the published property mean? | `local:legalName`. | -| Classification | How sensitive is the property and how must Relay handle it? | `potentially-personal`, `public-by-law`, `public`. | +| Classification | How sensitive is the property, and how must Relay handle it? | Potentially personal, public by law, and public handling. | | Processing description | Why is an operation offered, to which recipient class, and with which safeguards? | Statutory publication to the public. | The public property is the centre of the model. A source column is its local -binding, not its API name or semantic identity. This distinction allows a -property to be renamed, derived, combined, or reused under different -disclosure profiles without publishing storage internals. +binding, not its API name or semantic identity. The same public property can +therefore keep a stable meaning when an institution changes its storage schema, +provided the reviewed binding and source fingerprint change together. The +fingerprint is a digest of the reviewed SQLite structure, not of its row values. -## Generate local semantics first +## Generate a local semantic model first Every Registry contract declares a stable local vocabulary base. Every -resource names a local class, and every property names a local term. From that -small authored model, Relay generates: +resource names a local class, and every property names a local term. Relay then +generates: -- a local JSON-LD vocabulary with classes, properties, labels, descriptions, - datatypes, source requiredness, and codelist references; -- one JSON-LD context per visible operation; -- a JSON Schema for each permitted response representation; -- a SHACL shape for each operation and a complete operator-only source shape; -- codelist schemas and links; -- capability, classification, and processing artifacts. +- A local JSON-LD vocabulary with classes, properties, labels, descriptions, + data types, source requiredness, and codelist references. +- One JSON-LD context for each visible operation. +- A JSON Schema for each permitted response representation. +- A SHACL shape for each operation and a complete operator-only source shape. +- Codelist schemas and links. +- Capability, classification, and processing descriptions. -The generated model is useful without an external mapping. Stable local terms -make Records interpretable and give later mapping work an explicit source -vocabulary. Relay never guesses that two terms are equivalent. +The local model is usable without an external mapping. Stable local terms make +Records interpretable and give later mapping work an explicit source +vocabulary. Relay does not guess that two terms are equivalent. ## Add external alignments deliberately An institution can add a governed mapping file when a suitable public -vocabulary exists. The business acceptance Registry demonstrates a small SEMIC -Core Business Vocabulary alignment: - -```yaml -schemaVersion: relay.registrystack.org/semantic-alignment/v1alpha1 -profile: https://semiceu.github.io/Core-Business-Vocabulary/ -profileVersion: reviewed-2026-08-09 -profileDigest: sha256: -status: illustrative-pinned-input -mappings: - - local: local:RegisteredBusiness - external: https://data.europa.eu/m8g/LegalEntity - relation: close - - local: local:legalName - external: https://data.europa.eu/m8g/legalName - relation: close -``` - -Each mapping names an exact, close, broad, narrow, or related relation. The -profile version and digest make an external vocabulary change reviewable. -Relay compiles the file but never fetches or infers from remote vocabulary -content at runtime. - -SEMIC, PublicSchema, schema.org, and domain vocabularies are possible mapping -targets, not mandatory dependencies. A local term remains authoritative for -the Relay contract even when an external relation exists. - -## Keep source validation and response validation distinct +vocabulary exists. Each entry records: + +- The local class or property. +- The external class or property. +- Whether the relation is exact, close, broad, narrow, or related. +- The external profile identifier and reviewed version. +- A digest of the reviewed external profile material. + +For example, an institution can map `local:RegisteredBusiness` to the SEMIC +Core Business Vocabulary's `LegalEntity` class with a `close` relation. The +relation states a reviewed alignment without claiming that the two models are +identical. + +The European Commission Semantic Interoperability Community (SEMIC), +PublicSchema, schema.org, and domain vocabularies are possible mapping targets, +not runtime dependencies. Relay neither fetches nor infers from remote +vocabulary content when serving a request. The local term remains authoritative +for the Relay contract. + +## Validate the source and response separately The complete source Record and a caller-minimized response have different requiredness rules. -The operator-only full schema and SHACL shape validate every `sourceRequired` -property and Registry Core binding in the reviewed view. An invalid selected -source row is not partially released or coerced. No match, ambiguity, a hidden -row, and an unsafe row share the same unresolved public outcome where the -lookup contract requires indistinguishability. +The operator-only full JSON Schema and SHACL shape validate every +`sourceRequired` property and mandatory Record-context binding in the reviewed +view. Relay does not coerce or partially release an invalid selected row. For a +sensitive exact lookup, a missing Record, ambiguous result, hidden row, and +unsafe row can share the same unresolved response so the result does not reveal +which condition occurred. -An operation's response schema always requires Registry Core. Domain -properties are constrained when present but can be omitted through the -`fields` parameter. The schema reference returned with a Record therefore -validates the representation the caller actually received, not an imaginary -full row. +An operation's response schema always requires the mandatory Registry and +Record context. Domain properties are constrained when present but can be +omitted with the `fields` parameter. The schema linked from a returned Record +therefore validates the representation the caller received, not the complete +source row. ## Classify properties and hidden columns Each published property carries three classification dimensions: -- privacy category describes whether a value is personal, identifying, - sensitive, derived, or another governed category; -- institutional classification uses the Registry Authority's own scheme; -- technical handling selects the controls Relay must apply. +- A privacy category describes whether the value is personal, identifying, + sensitive, derived, or another reviewed category. +- An institutional classification uses the Registry Authority's own scheme. +- A technical handling level selects the controls Relay must apply. -The initial handling vocabulary is ordered from `public` through `internal` -and `confidential` to `restricted`. The compiler applies the most restrictive -effective handling across selected properties and all source columns used by -the operation. +The technical handling order is `public`, `internal`, `confidential`, and +`restricted`. Relay applies the most restrictive effective handling across all +properties and source columns used by the operation. Hidden columns still matter. Record revision, lifecycle, recorded time, -selectors, row bindings, filters, and ordering can affect an access or release -decision without appearing in `domainData`. Every reviewed hidden column needs -a technical classification. This closes the gap where a non-returned selector -or row-boundary column could be treated as harmless because it was absent from -the response. +selectors, row-authority claims, filters, and ordering can affect an access or +release decision without appearing in `domainData`. Every reviewed hidden +column needs a technical classification so a non-returned field cannot weaken +the operation's handling level. -Resource defaults keep authoring compact. The compiler expands defaults and -property or column exceptions into a complete effective classification. -Generated classifications remain suggestions until reviewed, and the -production profile refuses incomplete classification. +Resource defaults keep authoring compact. The compiler expands those defaults +and any property or column exceptions into a complete classification. Generated +classifications remain suggestions until an institution reviews them; +production checks refuse incomplete classification. -## Let handling narrow access, never widen it +## Allow classification to restrict, never grant -Classification is monotonic for security: +Classification can narrow an operation but cannot create authority: -- `public` data can be anonymous only through an explicitly public operation; -- non-public handling requires authentication, an operation scope, - `Cache-Control: no-store`, and durable value-free audit; +- `public` data is anonymous only through an explicitly public operation. +- Non-public handling requires authentication, an operation scope, + `Cache-Control: no-store`, and durable audit without Registry values. - `confidential` and `restricted` handling prevents public classification and - processing metadata; + processing metadata. - `restricted` data cannot be exposed through collection listing. -A classification label does not invent purpose, lawful basis, consent, or row -authority. Those remain explicit reviewed access and processing fields. A +A classification label does not invent a purpose, lawful basis, consent, or +row authority. Those remain explicit reviewed access and processing fields. A classification change can reduce availability or trigger review, but cannot create a route or grant a token scope. -## Use DPV as a governance projection +## Use DPV for governance metadata -The [Data Privacy Vocabulary 2.3](https://w3c-cg.github.io/dpv/2.3/dpv/) +The [Data Privacy Vocabulary (DPV) 2.3](https://w3c-cg.github.io/dpv/2.3/dpv/) can describe purposes, processing, parties, recipients, legal context, and -technical or organisational measures. Domain vocabularies describe what a +technical or organisational measures. A domain vocabulary describes what a Registry fact means; DPV describes why and how an operation processes it. -Relay can bind a reviewed DPV profile reference to a processing description. -DPV is not Relay's policy language. The runtime executes its small typed access -contract and never evaluates arbitrary RDF, DPV rules, ODRL, or remote content. -The current DPV document is a W3C Community Group report, so deployments pin -and review the chosen version rather than treating the vocabulary as an -unchanging authority. +A processing description can link to a reviewed DPV profile. Relay does not +use DPV as its runtime policy language. The service executes its smaller typed +access contract and does not evaluate arbitrary Resource Description Framework +(RDF), DPV rules, Open Digital Rights Language (ODRL), or remote content. DPV +2.3 is a W3C Community Group report, so an institution pins and reviews the +chosen version. -## Compile disclosure as a maximum +## Treat disclosure as a maximum -Every operation names one reviewed disclosure profile. The profile's property -list is both the default and the maximum. A caller can request a non-empty -subset with `fields`, but cannot add a property, select a source column, change -a derivation, bypass a row boundary, or reduce the operation's authentication, -audit, quota, handling, or metadata controls. +Every operation names one reviewed disclosure profile. Its property list is +both the default and the maximum. A caller can request a non-empty subset with +`fields`, but cannot add a property, select a source column, change a +derivation, bypass row authority, or reduce authentication, audit, quota, +handling, or metadata controls. -This is safe requester minimization, not dynamic attribute authorization. -Version 1 does not assign different maximum fields to two clients of the same -operation. Use separate named operations when two institutional purposes need -distinct reviewed representations, and keep the same Registry and Record -identifiers when both operations describe the same Record. +This is requester minimization, not per-client field authorization. Two clients +of the same operation share one maximum property set. Use separate named +operations when two institutional purposes need different reviewed +representations. Keep the same Registry and Record identifiers when both +operations describe the same Record. -## Publish semantics at the same visibility as the Record +## Publish semantics at the Record's visibility -Every Record carries `schemaReference` and `semanticModelReference`. The -compiler refuses a configuration where the successful audience cannot resolve -safe projections of both references. The JSON-LD context is linked separately -because a context maps terms to IRIs but does not, by itself, define the full -semantic model. +Every Record carries `schemaReference` and `semanticModelReference`. Relay +refuses a project where the successful audience cannot retrieve safe versions +of both artifacts. The JSON-LD context is linked separately because a context +maps terms to Internationalized Resource Identifiers but does not define the +complete semantic model by itself. Metadata visibility is part of disclosure: -- `public` artifacts can be retrieved anonymously; -- `operation-bound` artifacts require the same static gate as their operation; -- `operator-only` artifacts remain inside the sealed package and are never - mounted as HTTP content. +- `public` artifacts can be retrieved anonymously. +- `operation-bound` artifacts require the same static access rule as their + operation. +- `operator-only` artifacts remain in the sealed package and are not served + over HTTP. The full source schema, complete SHACL shape, authored mapping files, and -classification inventory can remain operator-only while each successful -caller receives the safe operation-specific artifacts needed to interpret its -Record. +classification inventory can remain operator-only. Each successful caller +still receives the operation-specific schema and semantic model needed to +interpret the returned Record. -These controls contribute technical evidence for privacy by design, +These controls provide technical evidence for data minimization, transparency, protection during use, and change-impact review. They do not create lawful basis, institutional accountability, remedy, independent oversight, or certification. Those responsibilities remain with the Registry Authority and its governance environment. -Continue with [Author a Registry Relay project](../../configure/relay/) to -apply this model, or [understand governed Registry publication](../governed-registry-publication/) -for the wider product boundary. +## Next + +- [Understand governed Registry publication](../governed-registry-publication/) + for the wider Relay product boundary. +- [Author a Registry Relay project](../../configure/relay/) to apply this model + to institution-owned SQLite views. +- [Operate Registry Relay](../../operate/relay/) to enforce artifact visibility, + authentication, audit, and source controls at deployment. diff --git a/docs/site/src/content/docs/operate/relay.mdx b/docs/site/src/content/docs/operate/relay.mdx index 0fd2d1e69..66893d53e 100644 --- a/docs/site/src/content/docs/operate/relay.mdx +++ b/docs/site/src/content/docs/operate/relay.mdx @@ -1,6 +1,6 @@ --- title: Operate Registry Relay -description: Bind a sealed Registry package to SQLite, authentication, audit, limits, and a private listener without weakening the governed contract. +description: Deploy one sealed Registry package with read-only SQLite, authentication, audit, limits, and a private listener. status: draft owner: registry-docs source_repos: @@ -11,43 +11,117 @@ locale: en standards_referenced: [] --- -Registry Relay starts from one sealed package and one local runtime file. The -package owns the Registry contract and every generated artifact. The runtime -binds deployment paths, a listener, SQLite sources, an optional token issuer, -audit storage, integrity secrets, quotas, and time limits. Startup verifies the -complete closure before binding the listener. +Deploy one reviewed Registry Relay package without allowing local runtime +settings to change its Registry identity, API, access rules, or disclosure. This +guide is for the Unix service operator who owns deployment paths, secrets, +authentication, audit retention, limits, readiness, and revision replacement. -## Prepare the deployment layout +## When to use this -Use separate locations for trusted configuration, read-only source data, -secrets, and writable audit state: +Use this guide after the data publisher gives you a sealed package, the +matching SQLite source, and an approved runtime plan. Return to +[Relay project authoring](../../configure/relay/) if the package or source +contract still needs review. + +Relay currently runs one Registry per process. Repeat the deployment as a +separate service when another Registry has a different Authority or +administrative trust boundary. + +## Before you start + +Prepare: + +- A dedicated Unix service identity, shown as `` and + `` in this guide. +- The `relay` binary and one sealed package produced by `relayctl package`. +- The matching SQLite snapshot, or a live SQLite source maintained by a + separate trusted publisher. +- An audit retention location and two independent integrity keys. +- For protected operations, one OpenID Connect issuer with discovery and key + endpoints reachable during startup. +- A reverse proxy or ingress that terminates Transport Layer Security (TLS). + +Run Relay on a private or loopback listener. Do not place the package, source, +secrets, or audit file in a shared writable directory. + +## Prepare trusted paths + +Create separate locations for trusted configuration, read-only source data, +secret material, and writable audit state: + +```sh +sudo install -d -o root -g root -m 0755 /etc/relay/business +sudo install -d -o root -g root -m 0755 /etc/relay/business/secrets +sudo install -d -o root -g root -m 0755 /srv/registries +sudo install -d -o -g -m 0700 /var/lib/relay/business +``` + +The resulting layout is: ```text /etc/relay/business/ runtime.yaml package/ - audit-integrity-key - cursor-integrity-key + secrets/ + audit-integrity-key + cursor-integrity-key /srv/registries/ business.sqlite /var/lib/relay/business/ audit.jsonl ``` -Relay's runtime and package trust checks depend on Unix ownership, modes, -no-follow opens, and file identity. Non-Unix targets fail closed. Every path -component must be owned by root or the Relay service identity. Group-writable -and world-writable ancestors are refused, except for a root-owned sticky -shared ancestor. Symlinks are refused. +Copy the package and snapshot into place using the institution's deployment +tooling. Make the package tree and snapshot non-writable to the Relay identity. +Make each secret readable only by that identity: + +```sh +sudo chown -R root:root /etc/relay/business/package +sudo chmod -R go-w /etc/relay/business/package +sudo chown root:root /srv/registries/business.sqlite +sudo chmod 0444 /srv/registries/business.sqlite +sudo chown : /etc/relay/business/secrets/* +sudo chmod 0400 /etc/relay/business/secrets/* +``` -Keep the runtime file, package, and snapshot source non-writable to the Relay -identity. Keep secret files owner-only and the audit directory writable only -by the Relay identity. Do not place the source database or audit file inside -the sealed package. +Relay validates every trusted path component before use. Each component must +be owned by root or the service identity, must not be group-writable or +world-writable, and must not be a symbolic link. A root-owned sticky shared +ancestor is the only writable-ancestor exception. Relay fails closed on +non-Unix systems because the same ownership and mode checks are unavailable. -## Bind one package and its sources +## Confirm the SQLite source profile -The runtime document is intentionally smaller than the Registry contract: +Confirm that the source profile already selected in the sealed Registry package +matches the deployment. The runtime cannot change it. Return an unsuitable +profile to the data publisher before deployment rather than modifying the +package locally. + +| Property | Snapshot | Live read-only | +| --- | --- | --- | +| Publisher updates while Relay runs | No | Yes, through a separate trusted process | +| File identity and content digest | Captured and enforced | Current path and open-handle identity enforced | +| SQLite journal and write-ahead-log files | Refused | SQLite-managed live state permitted by the contract | +| Per-request consistency | Immutable file | One read transaction | +| List and cursor pagination | Supported | Not supported | +| ETag and cache revalidation | Supported for cacheable public responses | Disabled | +| Source revision | Exact digest | Explicitly unversioned | + +Use snapshot for published extracts and reproducible public Registries. Use +live read-only when a separate trusted publisher must apply compatible updates +without restarting Relay. Live resources support identifier read and named +exact lookup, return `Cache-Control: no-store`, and make no historical +reproducibility claim. + +Both profiles pin the expected SQLite schema fingerprint. Relay verifies it +inside the same transaction as a live read and refuses schema drift, source +replacement, a moved open handle, unsafe snapshot journal files, writes, +unbounded results, and undeclared SQL behavior. + +## Create the runtime file + +Write `/etc/relay/business/runtime.yaml`. The runtime binds local deployment +resources but cannot change the reviewed Registry contract: ```yaml apiVersion: relay.registrystack.org/v2alpha1 @@ -62,9 +136,9 @@ authentication: issuer: null audit: sink: /var/lib/relay/business/audit.jsonl - integrityKeyRef: secret:file/audit-integrity-key + integrityKeyRef: secret:file/secrets/audit-integrity-key cursor: - integrityKeyRef: secret:file/cursor-integrity-key + integrityKeyRef: secret:file/secrets/cursor-integrity-key maximumAgeSeconds: 300 limits: requestTimeoutMilliseconds: 1500 @@ -76,141 +150,160 @@ shutdown: gracePeriodMilliseconds: 1000 ``` -Relative secret-file references resolve beneath the runtime directory. An -environment reference has the form `secret:env/NAME`. Relay never accepts a -secret value in the contract or runtime YAML. - -The runtime cannot change Registry identity, resources, source views, -properties, operations, disclosure profiles, access rules, classifications, -semantics, or metadata visibility. A runtime source identifier must match the -compiled package exactly. +Relative `secret:file/` paths resolve beneath the runtime directory. A secret +manager can instead expose `secret:env/NAME`. Relay never accepts the secret +value itself in the contract or runtime YAML. -## Choose snapshot or live read-only SQLite +Create independent random keys with the institution's secret manager. For a +local Unix deployment, write at least 32 random bytes to each owner-only file +without printing them: -Both source profiles open SQLite through the shared read-only executor. The -profile changes the provenance and consistency promise, not the API's -read-only boundary. +```sh +umask 077 +sudo install -o -g -m 0600 /dev/null \ + /etc/relay/business/secrets/audit-integrity-key +sudo install -o -g -m 0600 /dev/null \ + /etc/relay/business/secrets/cursor-integrity-key +openssl rand 32 | sudo -u tee /etc/relay/business/secrets/audit-integrity-key >/dev/null +openssl rand 32 | sudo -u tee /etc/relay/business/secrets/cursor-integrity-key >/dev/null +sudo chmod 0400 /etc/relay/business/secrets/audit-integrity-key \ + /etc/relay/business/secrets/cursor-integrity-key +``` -| Property | Snapshot | Live read-only | -| --- | --- | --- | -| Publisher updates during service life | No | Yes, through a separate trusted process | -| File identity and content digest | Captured and enforced | Current path and handle identity enforced | -| Sidecars | Unsafe sidecars refused | SQLite-managed live state permitted under the live contract | -| Per-request consistency | Immutable file | One read transaction | -| List and cursor | Supported | Not supported in Version 1 | -| ETag and cache revalidation | Supported for cacheable public responses | Disabled | -| Source revision | Exact digest | Explicitly unversioned | +Make the completed runtime file root-owned and non-writable: -Use snapshot for published extracts and reproducible public registries. Use -live read-only when another trusted process must publish compatible updates -without replacing the Relay process. Live resources support read and named -exact lookup only, return `Cache-Control: no-store`, and carry no historical -reproducibility claim. +```sh +sudo chown root:root /etc/relay/business/runtime.yaml +sudo chmod 0444 /etc/relay/business/runtime.yaml +``` -Both profiles pin the expected SQLite schema fingerprint. Relay verifies the -fingerprint inside the same transaction as a live read and refuses drift. A -source replacement, moved SQLite handle, unsafe snapshot sidecar, write -attempt, or incompatible schema fails closed -(`crates/registry-platform-sqlite/`). +The source identifier `companies` must match the sealed package. Runtime values +cannot change Registry identity, resources, views, properties, operations, +disclosure profiles, access rules, classifications, semantics, or metadata +visibility. ## Configure protected operations -Public-only registries can set `authentication.issuer: null`. A package with a -protected operation requires one issuer in the runtime. Relay fetches the -issuer's OpenID Connect discovery document and keys during startup, then -verifies a narrow JWT access-token profile on every protected request. +Keep `authentication.issuer: null` only when every operation is public. A +package with a protected operation requires one issuer in the runtime. Relay +loads the issuer's OpenID Connect discovery document and public keys during +startup, then verifies a narrow JSON Web Token access-token profile on every +protected request. The verified token must carry one exact audience, an accepted token type and algorithm, a trusted key identifier, bounded lifetime, issue and expiry times, a token identifier, a principal, and the operation scope. Optional purpose and -row-binding authority come from verified scalar claims named by the compiled -access rule. Request headers cannot create that authority. - -Registry Mint is one optional issuer for deployments without an existing -authorization server. Relay has no production dependency on Mint, no client -registry, and no token-issuance route. The issuer assigns scopes and claims; -the compiled package still defines the maximum operation set. - -## Treat audit as a release gate - -Every data operation, including anonymous public access, writes a durable -attempt before source access and a terminal event before response bytes are -released. A refusal is recorded before its problem response. An audit failure -blocks source access or discards held response bytes rather than releasing an -unaudited result. - -Audit events identify the Registry, resource, operation, processing -description, access-rule revision, purpose when present, row-boundary kind, -disclosure profile, selected property identifiers or digest, handling level, -contract revision, and source revision. Events do not contain tokens, -selectors, source values, response values, or raw subject identifiers -(`crates/registry-relay-v2/src/audit.rs`). +row authority come from verified scalar claims named by the access rule. HTTP +headers and query parameters cannot create that authority. -Protect the integrity key and audit path as one retained trust boundary. Relay -does not expose the raw audit chain as an Access Transparency API. A shared -cache hit served before Relay is also outside Relay's audit coverage. +Registry Mint is one optional issuer for an institution without an existing +authorization server. Relay has no token-issuance route or production runtime +dependency on Mint. The issuer assigns scopes and claims; the sealed package +still defines the maximum operation set. -## Start and probe the service +## Start and verify the service -Start one process from the exact runtime path: +Start Relay as `` from the exact runtime path: ```sh -relay serve --runtime /etc/relay/business/runtime.yaml +sudo -u /usr/local/bin/relay serve \ + --runtime /etc/relay/business/runtime.yaml ``` -Relay performs these steps before listening: +Relay stays in the foreground. It logs the private listener only after package, +source, issuer when configured, audit, secret, and readiness checks succeed. +The timestamp is omitted from this abridged log entry: -1. Validate the runtime file path, owner, mode, size, and file identity. -2. Load and verify the sealed package and every governed-file digest. -3. Re-observe the SQLite schema and source profile. -4. Open the read-only executor with its limits. -5. Resolve the issuer, audit sink, cursor key, and other secrets. -6. Construct the immutable service and pass readiness. -7. Bind the configured listener. +```json +{"level":"INFO","fields":{"message":"relay service listening","bind":"127.0.0.1:8080"},"target":"registry_relay_v2::startup"} +``` -Probe liveness from the same network boundary: +From the same network boundary, check liveness and readiness: ```sh relay healthcheck --url http://127.0.0.1:8080/health curl -fsS http://127.0.0.1:8080/ready ``` -`/health` proves the process can answer. `/ready` is successful only after the -package, source, issuer when configured, audit sink, and service state are -ready. Keep the listener on loopback or a private address and terminate TLS in -an operator-controlled reverse proxy or ingress. +The healthcheck exits with status `0`. Readiness returns: -## Use bounded operational logs +```json +{"status":"ready"} +``` -Relay writes JSON lifecycle and request-outcome logs to standard error. Request -outcomes contain only a fixed method, route template, status, latency, and -trace identifier. They do not contain request paths, identifiers, query -values, headers, bodies, selectors, or principals. +`/health` proves the process can answer. `/ready` confirms the loaded service +state remains ready. Publish only the intended API routes through the +operator-controlled TLS proxy or ingress. -`RELAY_LOG` accepts only `off`, `error`, `warn`, `info`, `debug`, or `trace` for -Relay-owned targets. An arbitrary tracing directive is ignored, which prevents -a deployment value from enabling dependency logs that may carry URLs or -headers. Derive metrics externally from the fixed value-free dimensions. +## Retain audit and operational logs + +Every data operation, including anonymous public access, writes a durable +attempt before SQLite access and a terminal event before response bytes are +released. If audit fails, Relay blocks source access or discards the held +response instead of releasing an unaudited result. + +Audit events identify the Registry, resource, operation, processing +description, access-rule revision, purpose when present, row-boundary kind, +disclosure profile, selected property identifiers or digest, handling level, +contract revision, and source revision. They exclude tokens, selectors, source +values, response values, and raw subject identifiers. Protect the audit path +and integrity key as one retention boundary. + +Relay writes JSON lifecycle and request-outcome logs to standard error. Request +outcomes contain only method, route template, status, latency, and trace +identifier. They exclude request paths, identifiers, query values, headers, +bodies, selectors, and principals. `RELAY_LOG` accepts `off`, `error`, `warn`, +`info`, `debug`, or `trace` for Relay-owned targets. Derive operational metrics +from these fixed dimensions outside the process. ## Deploy a new revision -Relay does not hot-reload or merge contracts. Use a complete replacement: +Relay does not hot-reload or merge packages. Replace one complete revision at a +time: -1. Build and review a new project revision with `relayctl check`, `generate`, - `test`, and `diff`. -2. Create a new sealed package path. Packaging refuses an existing output. -3. Prepare the matching source and runtime bindings without modifying the - active package. -4. Start a candidate process and wait for readiness. -5. Shift traffic through the operator-controlled proxy. +1. Receive a newly reviewed package, matching source, and approved change + report from the data publisher. +2. Install them at new revisioned paths without modifying the active package. +3. Start a candidate process on a private listener and wait for `/ready`. +4. Send one authorized smoke request through the same proxy policy used in + production. +5. Shift traffic to the candidate. 6. Drain the previous process and send `SIGTERM`. 7. Retain the package revision, source revision, audit segment, and change review according to institutional policy. -Rollback means activating a previously reviewed complete package with its -compatible source and runtime bindings. Relay never falls back to another -interpretation after startup failure. +Rollback activates a previously reviewed package with its compatible source +and runtime bindings. Relay never falls back to another interpretation after a +startup failure. -Continue with [Author a Registry Relay project](../../configure/relay/) for -the governed inputs and [understand Relay's product boundary](../../explanation/governed-registry-publication/) -for the one-Registry trust model. +## Verify the deployment + +Before accepting traffic, confirm: + +- The listener appears only after the startup checks complete. +- `/health` and `/ready` succeed from the proxy's network boundary. +- One public or authorized request returns the expected Registry identifier, + resource, and contract revision. +- The audit sink contains the matching attempt and terminal events. +- Process logs contain no request values, tokens, selectors, or Registry data. +- `SIGTERM` records a complete graceful shutdown in a staging run. + +## Next + +- [Author a Registry Relay project](../../configure/relay/) for contract and + change-review responsibilities. +- [Understand Relay's product boundary](../../explanation/governed-registry-publication/) + for the one-Registry trust model. +- [Review semantics, classification, and disclosure](../../explanation/relay-semantics-and-disclosure/) + for artifact visibility and field minimization. + +## Troubleshooting + +| Symptom | Cause | Fix | +| --- | --- | --- | +| Startup refuses an unsafe path | A path component is a symlink, has the wrong owner, or is writable by group or world | Move the deployment to trusted Unix paths and correct ownership and modes before retrying. | +| Startup reports a schema mismatch | The SQLite structure differs from the packaged fingerprint | Stop deployment and return the source and contract to the authoring change workflow. | +| Startup reports the issuer is not ready | Discovery, keys, issuer identity, or network policy does not match the runtime | Correct the issuer deployment; do not disable authentication for a protected package. | +| `/health` works but `/ready` fails | A loaded source, issuer, audit sink, or service dependency is no longer ready | Keep the service out of rotation and repair the failing dependency. | +| A response is withheld after a successful query | The terminal audit write failed | Restore the audit sink and verify its chain before accepting traffic. | +| A previous package will not start during rollback | Its source or runtime binding is no longer compatible | Restore the complete reviewed package, source, and runtime set rather than mixing revisions. | diff --git a/docs/site/src/content/docs/tutorials/publish-governed-sqlite-registry.mdx b/docs/site/src/content/docs/tutorials/publish-governed-sqlite-registry.mdx index 655ae1cfb..057e12d66 100644 --- a/docs/site/src/content/docs/tutorials/publish-governed-sqlite-registry.mdx +++ b/docs/site/src/content/docs/tutorials/publish-governed-sqlite-registry.mdx @@ -1,6 +1,6 @@ --- title: Publish a governed SQLite registry -description: Build Relay and relayctl, prove one synthetic business Registry, and read a minimized Record through the real HTTP service. +description: Run the supplied business Registry, verify its contract, and request a minimized Record through Registry Relay. status: draft owner: registry-docs source_repos: @@ -20,27 +20,33 @@ standards_referenced: import QuickstartMeta from '../../../components/QuickstartMeta.astro'; -Registry Relay turns a reviewed SQLite view and one Registry contract into a -read-only API, semantic artifacts, and auditable runtime behavior. In this -tutorial, you will build the Relay V2 candidate, prove the maintained synthetic -business Registry, start the real service, and request two selected properties -without exposing the SQLite table or column names. +Run a synthetic business Registry through Registry Relay, from a reviewed +SQLite view to a working read-only API. You will verify the supplied contract, +generate its documentation and semantic model, seal a deployment package, and +request only two permitted properties from the running service. -The project contains synthetic organisations and reserved `.invalid` service -names. Do not substitute production data, identifiers, or keys during this -first run. +## Before you start -## Build the candidate tools +This preview builds Relay from the source checkout. Run every command from the +repository root unless the tutorial tells you to change directory. -From the Registry Stack repository root, build `relayctl` and `relay` from the -same source revision: +The supplied project uses synthetic organisations and reserved `.invalid` +service names. Do not substitute production data, identifiers, or keys during +this first run. A data publisher reviews the Registry meaning and disclosure +rules. An operator binds the reviewed package to local files and starts the +service. You will perform both roles locally. + +## Build the tools + +Build `relayctl`, the project-authoring command, and `relay`, the service +command: ```sh repo_root="$(pwd -P)" @@ -50,13 +56,20 @@ relayctl --version relay --version ``` -`relayctl` owns the adopter workflow. `relay` owns startup verification and the -HTTP service. Both use the same compiler and runtime library. +The final two lines have this form. The version follows your checkout: + +```text +relayctl +relay +``` + +Both commands now use the same contract compiler. `relayctl` prepares and +checks a project; `relay` opens the checked package and serves its HTTP API. -## Copy the maintained Registry +## Prepare the sample Registry -Copy the business acceptance project into a disposable reader directory, then -materialize its SQLite snapshot from tracked SQL: +Copy the supplied business Registry into a disposable directory, then create +its SQLite snapshot from the tracked synthetic SQL: ```sh reader_root="$(mktemp -d "${TMPDIR:-/tmp}/relay-tutorial.XXXXXX")" @@ -77,65 +90,140 @@ database.chmod(0o444) PY ``` -The authored project has four kinds of input: +These commands print nothing when they succeed. `pwd -P` records the physical +path because Relay rejects symlinks in trusted runtime paths. + +The project contains one YAML contract, one deployment binding, and the files +needed to prove them: ```text -registry.yaml governed Registry, resources, operations, and disclosure -runtime.yaml local package, source, audit, secret, and listener bindings -fixture.sql synthetic source construction -expected-http.yaml executable HTTP journey +registry.yaml Registry identity, resources, operations, and disclosure +runtime.yaml local package, SQLite, audit, secret, and listener paths +fixture.sql synthetic SQLite source construction +expected-http.yaml expected requests and responses codelists/ reviewed controlled values -governance/ legal-basis and identifier-lifecycle material -semantics/ optional external vocabulary mappings +governance/ legal-basis and identifier-lifecycle records +semantics/ optional mappings to external vocabularies ``` -The SQLite database is an input to the deployment. The database is not copied -into the sealed package. +The Registry contract is the reviewed agreement about what the API means and +may release. The SQLite database remains a separate deployment input and is +not copied into the package. -## Prove the project +## Verify the project -Compile the production profile before generating or serving anything: +Compile the production checks before generating or serving anything: ```sh relayctl check "$project" --production ``` -The report has `"status": "success"`, an empty `diagnostics` array, and a -contract revision. Production mode refuses unreviewed semantic or -classification suggestions, missing source bindings, schema drift, and -inconsistent disclosure or access rules. +After the command label, the report begins with a successful status and no +diagnostics: -Generate the public and operator artifacts, then run the complete synthetic -journey through the real Relay router: +```json +{ + "status": "success", + "diagnostics": [], + "details": { + "kind": "check", + "production": true, + "contract_revision": "sha256:" + } +} +``` + +This is the first governed result. Production checks refuse unreviewed +semantic or classification suggestions, missing source bindings, schema +changes, and inconsistent access or disclosure rules. + +Generate the public and operator artifacts: ```sh relayctl generate "$project" +``` + +The abridged result identifies the generated artifact inventory: + +```text +relayctl generate +{ + "status": "success", + "diagnostics": [], + "details": { + "kind": "generate", + "contract_revision": "sha256:", + "artifacts": [ ... ] + } +} +``` + +Now replay every supplied HTTP request through the same router used by the +service: + +```sh relayctl test "$project" ``` -The generated directory includes OpenAPI 3.1, JSON Schema, SHACL, JSON-LD -contexts and vocabularies, codelist schemas, classification and processing -descriptions, and capability discovery. The fixture report covers successful -reads, pagination, filters, field minimization, JSON-LD equivalence, cache -revalidation, and safe refusals. +The test report lists each request and its observed result. Every supplied step +must record `"passed": true`: + +```text +relayctl test +{ + "status": "success", + "diagnostics": [], + "details": { + "kind": "test", + "contract_revision": "sha256:", + "report": { + "steps": [ + {"id": "registry-discovery", "actualStatus": 200, "passed": true}, + ... + ] + } + } +} +``` + +Generated files include OpenAPI 3.1, JSON Schema, Shapes Constraint Language +(SHACL), JSON for Linked Data (JSON-LD), codelist schemas, processing +descriptions, and capability discovery. ## Seal the deployment package -Package the governed contract and generated artifacts: +Package the reviewed contract and generated artifacts: ```sh relayctl package "$project" --output "$project/package" ``` -The package report records the contract revision, package revision, source -schema fingerprint, governed-file digests, generated artifacts, media types, -and visibility. `relay` verifies that inventory before opening a source, -issuer, audit sink, or listener. +The abridged report records both revisions and the source schema fingerprint: + +```text +relayctl package +{ + "status": "success", + "diagnostics": [], + "details": { + "kind": "package", + "manifest": { + "packageRevision": "sha256:", + "contractRevision": "sha256:", + "sourceSchemaFingerprints": {"companies": "sha256:"} + } + } +} +``` + +The package also inventories governed-file digests, media types, and artifact +visibility. Relay verifies that inventory before opening the database, audit +file, token issuer, or listener. ## Start Relay -Create disposable local integrity keys without printing their values, then -start the service: +Create disposable integrity keys without printing their values, then start the +service: ```sh export RELAY_TEST_AUDIT_KEY="$(python3 -c 'import secrets; print(secrets.token_hex(32))')" @@ -143,21 +231,37 @@ export RELAY_TEST_CURSOR_KEY="$(python3 -c 'import secrets; print(secrets.token_ relay serve --runtime "$project/runtime.yaml" ``` -Leave this terminal running. Startup logs end with a loopback listener at -`127.0.0.1:18082`. The business Registry is intentionally public, so this -synthetic first run has no token issuer. Protected registries use the same -runtime with one configured issuer. +Leave this terminal running after the listener message. The timestamp is +omitted from this abridged log entry: + +```json +{"level":"INFO","fields":{"message":"relay service listening","bind":"127.0.0.1:18082"},"target":"registry_relay_v2::startup"} +``` + +This Registry is intentionally public, so the local run needs no access +token. A protected deployment configures one OpenID Connect token issuer in +the runtime file. ## Read the Registry metadata -In a second terminal, read the service document: +In a second terminal, confirm that the service is ready: + +```sh +curl -fsS http://127.0.0.1:18082/ready +``` + +```json +{"status":"ready"} +``` + +Now read the service document: ```sh curl -fsS http://127.0.0.1:18082/v2 | python3 -m json.tool ``` -The document identifies one Registry, its Authority and operator, and two -compiled Consultation capabilities: +The document identifies one Registry, its accountable Authority and technical +operator, and two available read capabilities. This is an abridged response: ```json { @@ -170,8 +274,8 @@ compiled Consultation capabilities: } ``` -The displayed document is abridged. Relay derives the capability inventory -from compiled operations rather than accepting a second authored list. +Relay derives this inventory from the operations that passed compilation. The +project does not maintain a second capability list that can drift from the API. ## Request fewer properties @@ -184,7 +288,7 @@ curl -fsS \ | python3 -m json.tool ``` -Each item retains its Registry Core context while `domainData` contains only +Each item keeps the mandatory record context while `domainData` contains only the requested subset: ```json @@ -207,35 +311,52 @@ the requested subset: } ``` -The displayed response is abridged. The real item also carries resolvable -schema and semantic-model references, and the response metadata records the +The response is abridged. The complete item links to the schema and semantic +model that describe this representation. Response metadata records the operation, disclosure profile, selected fields, contract revision, and source -revision. A request for `legal_name`, an unknown field, arbitrary sorting, or -an undeclared filter receives a bounded problem instead of widening the query. +revision. An unknown field, SQLite column name, sort, or undeclared filter +receives a bounded problem response instead of changing the query. ## Stop the service -Return to the Relay terminal and press `Ctrl+C`. Relay completes a graceful -shutdown. Remove the disposable reader directory when you no longer need the -generated package or audit file, then clear the two local environment values. +Return to the Relay terminal and press `Ctrl+C`. Relay logs `relay shutdown +complete` after the graceful shutdown. Then remove the disposable project and +clear the local keys: -## What you proved +```sh +cd "$repo_root" +rm -rf -- "$reader_root" +unset RELAY_TEST_AUDIT_KEY RELAY_TEST_CURSOR_KEY +``` + +The cleanup commands print nothing when they succeed. + +## What you built -- SQLite tables and columns did not become routes by convention. - One reviewed contract produced the API, semantic artifacts, disclosure rules, and capability inventory. +- SQLite tables and columns did not become routes by convention. - The caller selected fewer properties but could not select more. -- Registry Core context remained present for every Record. -- The deployment package was verified before Relay opened its source or - listener. +- Mandatory Registry and Record context remained present in every result. +- Relay verified the sealed package before opening its source or listener. ## Next -- [Author a Registry Relay project](../../configure/relay/) to bind an - institution-owned SQLite view. -- [Operate Registry Relay](../../operate/relay/) to choose a source profile, - issuer, audit path, limits, and deployment permissions. - [Understand Relay's product boundary](../../explanation/governed-registry-publication/) - before introducing additional resources. + to decide whether Relay fits an institutional Registry. - [Review semantics, classification, and disclosure](../../explanation/relay-semantics-and-disclosure/) - before publishing real fields. + before assigning public names and handling levels to real fields. +- [Author a Registry Relay project](../../configure/relay/) to bind an + institution-owned SQLite view. +- [Operate Registry Relay](../../operate/relay/) to prepare source files, + authentication, audit, limits, and deployment permissions. + +## Troubleshooting + +| Symptom | Cause | Fix | +| --- | --- | --- | +| `cargo build` cannot find a package | The command is outside the Registry Stack root or the checkout predates Relay | Return to the source root and confirm that `crates/registry-relay-v2` exists. | +| Relay refuses a path containing `/tmp` | On macOS, `/tmp` is a symlink | Keep the `pwd -P` assignment so the runtime receives the physical path. | +| `relayctl check` returns diagnostics | A governed input or SQLite schema does not match the reviewed contract | Read the diagnostic identifiers, fix the authored input, and run `check --production` again. | +| `relay serve` reports that the address is in use | Another process is listening on port `18082` | Stop that process or change the sample listener consistently before packaging. | +| `/ready` is unavailable | Startup refused the package, source, audit path, keys, or listener | Read the value-free startup error in the Relay terminal and correct that deployment input. | From accbc16566a1796c31a01fa4595f8ab3585b12fc Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 10 Aug 2026 12:40:38 +0700 Subject: [PATCH 04/24] feat(relay): govern classified representations Signed-off-by: Jeremi Joslin --- .../assets/identification/core-pack-v1.json | 186 ++ crates/registry-relay-v2/src/api.rs | 727 +++++-- crates/registry-relay-v2/src/artifacts.rs | 581 ++++-- crates/registry-relay-v2/src/audit.rs | 17 +- crates/registry-relay-v2/src/compiler.rs | 1599 +++++++++++++-- crates/registry-relay-v2/src/contract.rs | 105 +- crates/registry-relay-v2/src/cursor.rs | 38 +- crates/registry-relay-v2/src/diff.rs | 210 +- crates/registry-relay-v2/src/fixtures.rs | 18 +- .../registry-relay-v2/src/identification.rs | 1741 +++++++++++++++++ crates/registry-relay-v2/src/lib.rs | 4 +- crates/registry-relay-v2/src/model.rs | 71 +- crates/registry-relay-v2/src/package.rs | 22 +- crates/registry-relay-v2/src/problem.rs | 15 +- crates/registry-relay-v2/src/semantics.rs | 14 + crates/registry-relay-v2/src/server.rs | 4 +- .../registry-relay-v2/src/sqlite_runtime.rs | 124 +- crates/registry-relay-v2/src/startup.rs | 38 +- crates/registry-relay-v2/src/tooling.rs | 176 +- crates/registry-relay-v2/src/transform.rs | 142 ++ .../tests/acceptance_http.rs | 61 +- .../registry-relay-v2/tests/identification.rs | 679 +++++++ .../tests/multi_resource_isolation.rs | 93 +- .../registry-relay-v2/tests/process_http.rs | 96 +- .../tests/representation_http.rs | 1203 ++++++++++++ .../site/src/content/docs/configure/relay.mdx | 486 ++--- .../governed-registry-publication.mdx | 272 +-- .../relay-semantics-and-disclosure.mdx | 293 ++- docs/site/src/content/docs/operate/relay.mdx | 347 +--- .../publish-governed-sqlite-registry.mdx | 162 +- products/relay-v2/CONCEPT.md | 125 +- products/relay-v2/CONFIGURATION-EXAMPLES.md | 227 ++- products/relay-v2/DEFINITION-OF-DONE.md | 59 +- products/relay-v2/IMPLEMENTATION.md | 58 +- products/relay-v2/STANDARDS-ALIGNMENT.md | 5 + .../business-registry/expected-http.yaml | 36 +- .../acceptance/business-registry/fixture.sql | 16 +- .../classification-review-rationale.md | 5 + .../governance/classification-review.yaml | 9 + .../business-registry/registry.yaml | 44 +- .../acceptance/business-registry/runtime.yaml | 7 +- .../acceptance/civil-event/expected-http.yaml | 44 +- .../acceptance/civil-event/fixture.sql | 3 +- .../classification-review-rationale.md | 4 + .../governance/classification-review.yaml | 9 + .../acceptance/civil-event/registry.yaml | 56 +- .../acceptance/civil-event/runtime.yaml | 2 +- .../social-assistance/expected-http.yaml | 66 +- .../acceptance/social-assistance/fixture.sql | 15 +- .../classification-review-rationale.md | 5 + .../governance/classification-review.yaml | 16 + .../social-assistance/registry.yaml | 42 +- .../reports/identification-report.json | 1 + .../acceptance/social-assistance/runtime.yaml | 2 +- .../contracts/acceptance-scenario-matrix.yaml | 12 + .../contracts/artifact-inventory.yaml | 38 +- .../contracts/generated-baselines.yaml | 564 ++++-- .../relay-v2/contracts/package-layout.yaml | 1 + .../contracts/security-invariant-matrix.yaml | 51 + products/relay-v2/scripts/check-contracts.sh | 4 +- .../relay-v2/scripts/test_adopter_workflow.py | 97 +- .../scripts/test_adopter_workflow_openapi.py | 73 + .../relay-v2/scripts/test_validate_product.py | 69 + products/relay-v2/scripts/validate_product.py | 150 +- 64 files changed, 9301 insertions(+), 2138 deletions(-) create mode 100644 crates/registry-relay-v2/assets/identification/core-pack-v1.json create mode 100644 crates/registry-relay-v2/src/identification.rs create mode 100644 crates/registry-relay-v2/src/transform.rs create mode 100644 crates/registry-relay-v2/tests/identification.rs create mode 100644 crates/registry-relay-v2/tests/representation_http.rs create mode 100644 products/relay-v2/acceptance/business-registry/governance/classification-review-rationale.md create mode 100644 products/relay-v2/acceptance/business-registry/governance/classification-review.yaml create mode 100644 products/relay-v2/acceptance/civil-event/governance/classification-review-rationale.md create mode 100644 products/relay-v2/acceptance/civil-event/governance/classification-review.yaml create mode 100644 products/relay-v2/acceptance/social-assistance/governance/classification-review-rationale.md create mode 100644 products/relay-v2/acceptance/social-assistance/governance/classification-review.yaml create mode 100644 products/relay-v2/acceptance/social-assistance/reports/identification-report.json create mode 100644 products/relay-v2/scripts/test_adopter_workflow_openapi.py diff --git a/crates/registry-relay-v2/assets/identification/core-pack-v1.json b/crates/registry-relay-v2/assets/identification/core-pack-v1.json new file mode 100644 index 000000000..7b3602fa3 --- /dev/null +++ b/crates/registry-relay-v2/assets/identification/core-pack-v1.json @@ -0,0 +1,186 @@ +{ + "packId": "registrystack.relay.identification.core", + "packVersion": "1", + "privacyCandidateVocabulary": { + "scheme": "urn:registrystack:relay:privacy-candidate", + "version": "1" + }, + "rules": [ + { + "id": "core.role.record-identifier", + "version": "1", + "family": "identifiers", + "confidence": "exact", + "when": [{"kind": "authored-role", "value": "record-identifier"}], + "suggestion": {"role": "record-identifier", "privacy": []} + }, + { + "id": "core.role.revision-identifier", + "version": "1", + "family": "revisions", + "confidence": "exact", + "when": [{"kind": "authored-role", "value": "revision-identifier"}], + "suggestion": {"role": "revision-identifier", "privacy": []} + }, + { + "id": "core.role.lifecycle-state", + "version": "1", + "family": "lifecycle", + "confidence": "exact", + "when": [{"kind": "authored-role", "value": "lifecycle-state"}], + "suggestion": {"role": "lifecycle-state", "privacy": []} + }, + { + "id": "core.role.recorded-at", + "version": "1", + "family": "times", + "confidence": "exact", + "when": [{"kind": "authored-role", "value": "recorded-at"}], + "suggestion": {"role": "recorded-time", "privacy": []} + }, + { + "id": "core.role.codelist", + "version": "1", + "family": "codelists", + "confidence": "exact", + "when": [{"kind": "codelist-present"}], + "suggestion": {"role": "codelist", "privacy": []} + }, + { + "id": "core.role.row-binding", + "version": "1", + "family": "identifiers", + "confidence": "strong", + "when": [{"kind": "authored-role", "value": "row-binding"}], + "suggestion": {"role": "identifier", "privacy": ["potentially-personal"]} + }, + { + "id": "core.key.primary", + "version": "1", + "family": "identifiers", + "confidence": "strong", + "when": [{"kind": "primary-key"}], + "suggestion": {"role": "identifier", "privacy": []} + }, + { + "id": "core.name.record-identifier", + "version": "1", + "family": "identifiers", + "confidence": "strong", + "when": [{"kind": "name-equals", "values": ["id", "record_id", "record_identifier"]}], + "suggestion": {"role": "record-identifier", "privacy": []} + }, + { + "id": "core.name.identifier-suffix", + "version": "1", + "family": "identifiers", + "confidence": "weak", + "when": [{"kind": "name-suffix", "values": ["_id", "_identifier"]}], + "suggestion": {"role": "identifier", "privacy": []} + }, + { + "id": "core.name.revision", + "version": "1", + "family": "revisions", + "confidence": "strong", + "when": [{"kind": "name-equals", "values": ["rev", "revision", "revision_id", "revision_identifier", "version"]}], + "suggestion": {"role": "revision-identifier", "privacy": []} + }, + { + "id": "core.name.lifecycle", + "version": "1", + "family": "lifecycle", + "confidence": "strong", + "when": [{"kind": "name-equals", "values": ["lifecycle", "lifecycle_state", "record_state", "state", "status"]}], + "suggestion": {"role": "lifecycle-state", "privacy": []} + }, + { + "id": "core.name.recorded-time", + "version": "1", + "family": "times", + "confidence": "strong", + "when": [{"kind": "name-equals", "values": ["created_at", "recorded_at", "timestamp", "updated_at"]}], + "suggestion": {"role": "recorded-time", "privacy": []} + }, + { + "id": "core.name.time-suffix", + "version": "1", + "family": "times", + "confidence": "weak", + "when": [{"kind": "name-suffix", "values": ["_at", "_date", "_datetime", "_time", "_timestamp"]}], + "suggestion": {"role": "recorded-time", "privacy": []} + }, + { + "id": "core.type.temporal", + "version": "1", + "family": "times", + "confidence": "weak", + "when": [{"kind": "declared-type", "values": ["date", "datetime", "timestamp"]}], + "suggestion": {"role": "recorded-time", "privacy": []} + }, + { + "id": "core.name.geographic-code", + "version": "1", + "family": "geographic-codes", + "confidence": "strong", + "when": [{"kind": "name-equals", "values": ["country_code", "geo_code", "geographic_code", "location_code", "region_code"]}], + "suggestion": {"role": "geographic-code", "privacy": ["potentially-personal"]} + }, + { + "id": "core.name.administrative-code", + "version": "1", + "family": "administrative-codes", + "confidence": "strong", + "when": [{"kind": "name-equals", "values": ["admin_code", "administrative_code", "district_code", "municipality_code", "province_code"]}], + "suggestion": {"role": "administrative-code", "privacy": ["potentially-personal"]} + }, + { + "id": "core.name.email", + "version": "1", + "family": "contact", + "confidence": "strong", + "when": [{"kind": "name-equals", "values": ["e_mail", "email", "email_address"]}], + "suggestion": {"role": "email-address", "privacy": ["identifying"]} + }, + { + "id": "core.name.email-token", + "version": "1", + "family": "contact", + "confidence": "weak", + "when": [{"kind": "name-token-any", "values": ["email"]}], + "suggestion": {"role": "email-address", "privacy": ["identifying"]} + }, + { + "id": "core.name.telephone", + "version": "1", + "family": "contact", + "confidence": "strong", + "when": [{"kind": "name-equals", "values": ["mobile", "mobile_number", "phone", "phone_number", "telephone", "telephone_number"]}], + "suggestion": {"role": "telephone-number", "privacy": ["identifying"]} + }, + { + "id": "core.name.telephone-token", + "version": "1", + "family": "contact", + "confidence": "weak", + "when": [{"kind": "name-token-any", "values": ["mobile", "phone", "telephone"]}], + "suggestion": {"role": "telephone-number", "privacy": ["identifying"]} + }, + { + "id": "core.name.person-reference", + "version": "1", + "family": "person-references", + "confidence": "strong", + "when": [{"kind": "name-equals", "values": ["individual_id", "individual_reference", "person_id", "person_ref", "person_reference", "subject_id", "subject_reference"]}], + "suggestion": {"role": "person-reference", "privacy": ["identifying"]} + }, + { + "id": "core.column.fallback", + "version": "1", + "family": "columns", + "confidence": "weak", + "when": [{"kind": "any"}], + "suggestion": {"role": "property", "privacy": []} + } + ] +} diff --git a/crates/registry-relay-v2/src/api.rs b/crates/registry-relay-v2/src/api.rs index 6ee91fe2d..a911ee897 100644 --- a/crates/registry-relay-v2/src/api.rs +++ b/crates/registry-relay-v2/src/api.rs @@ -25,11 +25,13 @@ use crate::cursor::{ CursorBindings, CursorPayload, CursorValue, }; use crate::model::{ - CompiledAccess, CompiledOperation, CompiledResource, OperationKind, RowAuthoritySource, + CompiledAccess, CompiledOperation, CompiledRepresentation, CompiledResource, OperationKind, + RowAuthoritySource, }; use crate::problem::{ProblemCode, TraceContext}; use crate::server::{uri_within_bound, RelayService}; use crate::sqlite_runtime::{OperationQuery, SourceRevision, SqliteRuntimeError}; +use crate::transform; const PRODUCT_NAME: &str = "Registry Relay"; const PRODUCT_VERSION: &str = "2"; @@ -40,12 +42,12 @@ const METADATA_MAXIMUM_PAGE_SIZE: usize = 100; const MAXIMUM_SERIALIZED_RESPONSE_BYTES: usize = 8 * 1024 * 1024; #[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum Representation { +enum ResponseFormat { Json, JsonLd, } -impl Representation { +impl ResponseFormat { const fn media_type(self) -> &'static str { match self { Self::Json => "application/json", @@ -58,6 +60,7 @@ impl Representation { struct Access { principal: Option, authorization: Authorization, + representation: CompiledRepresentation, } pub async fn health() -> Response { @@ -109,28 +112,15 @@ pub async fn service_metadata( let mut capabilities = Vec::new(); if service.registry.metadata_visibility.resources != Visibility::OperatorOnly { for resource in &service.registry.resources { - let operations = match service.registry.metadata_visibility.resources { - Visibility::Public => resource - .operations - .iter() - .filter(|operation| matches!(operation.access, CompiledAccess::Public)) - .collect::>(), - Visibility::OperationBound => match principal.as_ref() { - Some(principal) => { - match visible_operations(&service, resource, Some(principal)).await { - Ok(value) => value, - Err(code) => return code.response(&trace), - } - } - None => Vec::new(), - }, - Visibility::OperatorOnly => Vec::new(), + let operations = match visible_operations(&service, resource, principal.as_ref()).await + { + Ok(value) => value, + Err(ProblemCode::MissingCredential) => Vec::new(), + Err(code) => return code.response(&trace), }; - capabilities.extend( - operations - .into_iter() - .map(|operation| capability(&service, resource, operation)), - ); + capabilities.extend(operations.into_iter().map(|(operation, representation)| { + capability(&service, resource, operation, representation) + })); } } let alignment_targets = service @@ -361,11 +351,22 @@ pub async fn artifact( let Some(operation) = find_operation_by_id(&service, identifier) else { return ProblemCode::ResourceNotFound.response(&trace); }; + let Some(representation_identifier) = artifact.representation_identifier.as_deref() + else { + return ProblemCode::ResourceNotFound.response(&trace); + }; + let Some(representation) = operation + .representations + .iter() + .find(|representation| representation.id == representation_identifier) + else { + return ProblemCode::ResourceNotFound.response(&trace); + }; let Some(authenticator) = &service.authenticator else { return ProblemCode::ResourceNotFound.response(&trace); }; if authenticator - .authorize(&operation.access, Some(principal)) + .authorize(&representation.access, Some(principal)) .is_err() { return ProblemCode::ResourceNotFound.response(&trace); @@ -405,7 +406,16 @@ pub async fn record_list( ) .await; } - let access = match access_operation(&service, resource, operation, &headers, &trace).await { + let access = match access_operation( + &service, + resource, + operation, + uri.query(), + &headers, + &trace, + ) + .await + { Ok(value) => value, Err(response) => return response, }; @@ -421,7 +431,7 @@ pub async fn record_list( ) .await; } - let representation = match negotiate(&headers) { + let response_format = match negotiate(&headers) { Ok(value) => value, Err(code) => { return refuse_known( @@ -467,7 +477,7 @@ pub async fn record_list( &service, resource, operation, - &access, + Some(&access), query.selected_fields.clone(), &trace, ); @@ -478,6 +488,7 @@ pub async fn record_list( .sqlite .execute( &operation.identifier, + &access.representation.id, OperationQuery { filters: query.filters.clone(), row_authority: access.authorization.row_authority.clone(), @@ -498,19 +509,22 @@ pub async fn record_list( } let mut items = Vec::with_capacity(rows.len()); for row in &rows { - let record = match record_value(&service, resource, operation, row, &query.selected_fields) - { - Some(value) => value, - None => { - if service - .audit - .terminal(&audit, AuditOutcome::InternalFailed, None) - .await - .is_err() - { - return ProblemCode::AuditUnavailable.response(&trace); - } - return ProblemCode::Internal.response(&trace); + if !valid_cursor_order_values(&operation.query.order_by, row) { + return source_shape_failure(&service.audit, &audit, &trace).await; + } + let record = match record_value( + &service, + resource, + &access.representation, + row, + &query.selected_fields, + ) { + Ok(value) => value, + Err(RecordError::InvalidSource) => { + return source_shape_failure(&service.audit, &audit, &trace).await + } + Err(RecordError::InvalidCore) => { + return source_shape_failure(&service.audit, &audit, &trace).await } }; items.push(record); @@ -556,17 +570,24 @@ pub async fn record_list( &service, resource, operation, + &access.representation, &query.selected_fields, &result.source_revision, ), }); - apply_json_ld(&service, resource, operation, representation, &mut document); + apply_json_ld( + &service, + resource, + &access.representation, + response_format, + &mut document, + ); release_document( &service, &audit, document, - representation, - cacheable(operation, &result.source_revision), + response_format, + cacheable(&access.representation, &result.source_revision), &headers, &trace, ) @@ -585,22 +606,31 @@ pub async fn record_read( }) else { return unknown_data_route(&service, &headers, &trace, OperationClass::Read).await; }; - let access = match access_operation(&service, resource, operation, &headers, &trace).await { - Ok(value) => value, - Err(response) => return response, - }; if !uri_within_bound(&uri) { return refuse_known( &service, resource, operation, - Some(&access), + None, AuditOutcome::InvalidRequest, ProblemCode::UriTooLong, &trace, ) .await; } + let access = match access_operation( + &service, + resource, + operation, + uri.query(), + &headers, + &trace, + ) + .await + { + Ok(value) => value, + Err(response) => return response, + }; if !valid_record_identifier(&record_identifier) { return refuse_known( &service, @@ -647,23 +677,31 @@ pub async fn record_lookup( return unknown_data_route(&service, request.headers(), &trace, OperationClass::Lookup) .await; }; - let access = - match access_operation(&service, resource, operation, request.headers(), &trace).await { - Ok(value) => value, - Err(response) => return response, - }; if !uri_within_bound(request.uri()) { return refuse_known( &service, resource, operation, - Some(&access), + None, AuditOutcome::InvalidRequest, ProblemCode::UriTooLong, &trace, ) .await; } + let access = match access_operation( + &service, + resource, + operation, + request.uri().query(), + request.headers(), + &trace, + ) + .await + { + Ok(value) => value, + Err(response) => return response, + }; if rejects_caller_purpose(request.headers()) { return refuse_known( &service, @@ -676,7 +714,7 @@ pub async fn record_lookup( ) .await; } - let representation = match negotiate(request.headers()) { + let response_format = match negotiate(request.headers()) { Ok(value) => value, Err(code) => { return refuse_known( @@ -691,7 +729,12 @@ pub async fn record_lookup( .await } }; - let fields = match selected_fields(resource, operation, request.uri().query()) { + let fields = match selected_fields( + resource, + operation, + &access.representation, + request.uri().query(), + ) { Ok(value) => value, Err(code) => { return refuse_known( @@ -785,7 +828,7 @@ pub async fn record_lookup( selectors, ..OperationQuery::default() }, - prevalidated: Some((representation, fields)), + prevalidated: Some((response_format, fields)), quota_admitted: true, trace: &trace, }, @@ -808,7 +851,7 @@ struct SingleRequest<'a> { headers: &'a HeaderMap, query_text: Option<&'a str>, query: OperationQuery, - prevalidated: Option<(Representation, Vec)>, + prevalidated: Option<(ResponseFormat, Vec)>, quota_admitted: bool, trace: &'a TraceContext, } @@ -852,7 +895,12 @@ async fn single_operation( .await } }; - let fields = match selected_fields(resource, operation, request.query_text) { + let fields = match selected_fields( + resource, + operation, + &access.representation, + request.query_text, + ) { Ok(value) => value, Err(code) => { return refuse_known( @@ -877,14 +925,25 @@ async fn single_operation( return response; } } - let audit = audit_context(service, resource, operation, &access, fields.clone(), trace); + let audit = audit_context( + service, + resource, + operation, + Some(&access), + fields.clone(), + trace, + ); if service.audit.attempt(&audit).await.is_err() { return ProblemCode::AuditUnavailable.response(trace); } request.query.row_authority = access.authorization.row_authority.clone(); let result = service .sqlite - .execute(&operation.identifier, request.query) + .execute( + &operation.identifier, + &access.representation.id, + request.query, + ) .await; let result = match result { Ok(value) => value, @@ -901,28 +960,52 @@ async fn single_operation( } return ProblemCode::ConsultationUnresolved.response(trace); } - let Some(record) = record_value(service, resource, operation, &result.rows[0], &fields) else { - if service - .audit - .terminal(&audit, AuditOutcome::Unresolved, None) - .await - .is_err() - { - return ProblemCode::AuditUnavailable.response(trace); + let record = match record_value( + service, + resource, + &access.representation, + &result.rows[0], + &fields, + ) { + Ok(value) => value, + Err(RecordError::InvalidSource | RecordError::InvalidCore) => { + if matches!(operation.kind, OperationKind::Lookup { .. }) { + return terminal_problem( + &service.audit, + &audit, + AuditOutcome::Unresolved, + ProblemCode::ConsultationUnresolved, + trace, + ) + .await; + } + return source_shape_failure(&service.audit, &audit, trace).await; } - return ProblemCode::ConsultationUnresolved.response(trace); }; let mut document = json!({ "data": record, - "meta": record_meta(service, resource, operation, &fields, &result.source_revision), + "meta": record_meta( + service, + resource, + operation, + &access.representation, + &fields, + &result.source_revision, + ), }); - apply_json_ld(service, resource, operation, representation, &mut document); + apply_json_ld( + service, + resource, + &access.representation, + representation, + &mut document, + ); release_document( service, &audit, document, representation, - cacheable(operation, &result.source_revision), + cacheable(&access.representation, &result.source_revision), headers, trace, ) @@ -933,27 +1016,49 @@ async fn access_operation( service: &RelayService, resource: &CompiledResource, operation: &CompiledOperation, + query: Option<&str>, headers: &HeaderMap, trace: &TraceContext, ) -> Result> { let principal = match optional_principal(service, headers).await { Ok(value) => value, Err(code) => { - return Err(refuse_known( + return Err(refuse_before_representation( service, resource, operation, - None, + PrincipalKind::Unknown, AuditOutcome::InvalidCredential, code, trace, ) - .await) + .await); } }; + let selected = match select_representation(operation, query) { + Ok(value) => value, + Err(code) => { + let outcome = if code == ProblemCode::RepresentationNotFound { + AuditOutcome::NotFound + } else { + AuditOutcome::InvalidRequest + }; + return Err(refuse_before_representation( + service, + resource, + operation, + principal_kind(principal.as_ref()), + outcome, + code, + trace, + ) + .await); + } + }; + let representation = selected.representation; let authorization = match &service.authenticator { - Some(authenticator) => authenticator.authorize(&operation.access, principal.as_ref()), - None => match operation.access { + Some(authenticator) => authenticator.authorize(&representation.access, principal.as_ref()), + None => match representation.access { CompiledAccess::Public => Ok(Authorization { row_authority: None, purpose: None, @@ -965,6 +1070,7 @@ async fn access_operation( Ok(authorization) => Ok(Access { principal, authorization, + representation: representation.clone(), }), Err(error) => { let (code, outcome) = match error { @@ -984,6 +1090,7 @@ async fn access_operation( row_authority: None, purpose: None, }, + representation: representation.clone(), }; Err(refuse_known( service, @@ -1064,7 +1171,9 @@ async fn unknown_data_route( let protected = service.registry.resources.iter().any(|resource| { resource.operations.iter().any(|operation| { class_matches(&operation.kind, class) - && matches!(operation.access, CompiledAccess::Protected { .. }) + && operation.representations.iter().any(|representation| { + matches!(representation.access, CompiledAccess::Protected { .. }) + }) }) }); if protected && principal.is_none() { @@ -1117,14 +1226,24 @@ async fn refuse_known( code: ProblemCode, trace: &TraceContext, ) -> Response { - let access = access.cloned().unwrap_or(Access { - principal: None, - authorization: Authorization { - row_authority: None, - purpose: None, - }, - }); - let context = audit_context(service, resource, operation, &access, Vec::new(), trace); + let context = audit_context(service, resource, operation, access, Vec::new(), trace); + if service.audit.refusal(&context, outcome).await.is_err() { + return ProblemCode::AuditUnavailable.response(trace); + } + code.response(trace) +} + +async fn refuse_before_representation( + service: &RelayService, + resource: &CompiledResource, + operation: &CompiledOperation, + principal_kind: PrincipalKind, + outcome: AuditOutcome, + code: ProblemCode, + trace: &TraceContext, +) -> Response { + let mut context = audit_context(service, resource, operation, None, Vec::new(), trace); + context.principal_kind = principal_kind; if service.audit.refusal(&context, outcome).await.is_err() { return ProblemCode::AuditUnavailable.response(trace); } @@ -1146,7 +1265,14 @@ async fn quota_refusal( if !denied { return None; } - let context = audit_context(service, resource, operation, access, fields.to_vec(), trace); + let context = audit_context( + service, + resource, + operation, + Some(access), + fields.to_vec(), + trace, + ); if service .audit .refusal(&context, AuditOutcome::RateLimited) @@ -1162,7 +1288,7 @@ fn audit_context( service: &RelayService, resource: &CompiledResource, operation: &CompiledOperation, - access: &Access, + access: Option<&Access>, selected_properties: Vec, trace: &TraceContext, ) -> AuditContext { @@ -1172,23 +1298,38 @@ fn audit_context( registry_identifier: service.registry.registry_identifier.clone(), resource_identifier: Some(resource.id.clone()), operation_identifier: Some(operation.identifier.clone()), - access_rule_revision: access_revision(operation), - purpose: access.authorization.purpose.clone(), - row_boundary_kind: row_boundary(operation), - disclosure_profile: Some(operation.disclosure_profile.clone()), + access_rule_revision: access.map(|access| access_revision(&access.representation)), + purpose: access.and_then(|access| access.authorization.purpose.clone()), + row_boundary_kind: access.map_or(RowBoundaryKind::Unknown, |access| { + row_boundary(&access.representation) + }), + representation: access.map(|access| access.representation.id.clone()), + disclosure_profile: access.map(|access| access.representation.disclosure_profile.clone()), processing_description_identifiers: processing_description_identifiers(resource, operation), selected_properties, - maximum_handling: Some(handling_label(operation.maximum_handling).into()), + processing_handling: access + .map(|access| handling_label(access.representation.processing_handling).into()), + disclosure_handling: access + .map(|access| handling_label(access.representation.disclosure_handling).into()), + transform_identifiers: access.map_or_else(Vec::new, |access| { + transform_identifiers(&access.representation) + }), contract_revision: service.registry.contract_revision.clone(), source_revision: service .sqlite .source_revision(&operation.identifier) .cloned(), - principal_kind: if access.principal.is_some() { - PrincipalKind::Authenticated - } else { - PrincipalKind::Anonymous - }, + principal_kind: access.map_or(PrincipalKind::Anonymous, |access| { + principal_kind(access.principal.as_ref()) + }), + } +} + +fn principal_kind(principal: Option<&Principal>) -> PrincipalKind { + if principal.is_some() { + PrincipalKind::Authenticated + } else { + PrincipalKind::Anonymous } } @@ -1206,10 +1347,13 @@ fn unknown_audit_context( access_rule_revision: None, purpose: None, row_boundary_kind: RowBoundaryKind::Unknown, + representation: None, disclosure_profile: None, processing_description_identifiers: Vec::new(), selected_properties: Vec::new(), - maximum_handling: None, + processing_handling: None, + disclosure_handling: None, + transform_identifiers: Vec::new(), contract_revision: service.registry.contract_revision.clone(), source_revision: None, principal_kind, @@ -1235,14 +1379,29 @@ fn processing_description_identifiers( .collect() } -fn access_revision(operation: &CompiledOperation) -> Option { - let value = serde_json::to_value(&operation.access).ok()?; - let bytes = canonicalize_json(&value).ok()?; - Some(format!("sha256:{}", hex::encode(Sha256::digest(bytes)))) +fn access_revision(representation: &CompiledRepresentation) -> String { + let value = serde_json::to_value(&representation.access) + .expect("compiled representation access serializes"); + let bytes = canonicalize_json(&value).expect("compiled representation access canonicalizes"); + format!("sha256:{}", hex::encode(Sha256::digest(bytes))) } -fn row_boundary(operation: &CompiledOperation) -> RowBoundaryKind { - match &operation.access { +fn transform_identifiers(representation: &CompiledRepresentation) -> Vec { + representation + .transform_inventory + .iter() + .filter_map(|entry| { + entry + .split_once('=') + .map(|(_, identifier)| identifier.to_owned()) + }) + .collect::>() + .into_iter() + .collect() +} + +fn row_boundary(representation: &CompiledRepresentation) -> RowBoundaryKind { + match &representation.access { CompiledAccess::Protected { row_binding: Some(binding), .. @@ -1273,6 +1432,40 @@ struct PreparedList { after_order: Option>, } +struct SelectedRepresentation<'a> { + representation: &'a CompiledRepresentation, +} + +fn select_representation<'a>( + operation: &'a CompiledOperation, + query: Option<&str>, +) -> Result, ProblemCode> { + let parameters = parse_query(query)?; + let requested = one_parameter(¶meters, "representation") + .map_err(|_| ProblemCode::RepresentationInvalid)?; + let identifier = requested.unwrap_or(&operation.default_representation); + if !valid_representation_identifier(identifier) { + return Err(ProblemCode::RepresentationInvalid); + } + operation + .representations + .iter() + .find(|representation| representation.id == identifier) + .map(|representation| SelectedRepresentation { representation }) + .ok_or(ProblemCode::RepresentationNotFound) +} + +fn valid_representation_identifier(value: &str) -> bool { + !value.is_empty() + && value.len() <= 128 + && !value.starts_with('-') + && !value.ends_with('-') + && !value.contains("--") + && value + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') +} + fn prepare_list( service: &RelayService, resource: &CompiledResource, @@ -1291,7 +1484,11 @@ fn prepare_list( .as_ref() .ok_or(ProblemCode::Internal)?; if !cursors.is_empty() { - if cursors.len() != 1 || parameters.len() != 1 { + if cursors.len() != 1 + || parameters + .iter() + .any(|(name, _)| name != "cursor" && name != "representation") + { return Err(ProblemCode::CursorInvalid); } let key = service @@ -1312,7 +1509,12 @@ fn prepare_list( .map(|(name, value)| (name.clone(), cursor_to_sql(value.clone()))) .collect::>(); validate_filter_inventory(operation, &filters)?; - validate_selected_inventory(resource, operation, &payload.selected_fields)?; + validate_selected_inventory( + resource, + operation, + &access.representation, + &payload.selected_fields, + )?; let current_source_revision = service .sqlite .source_revision(&operation.identifier) @@ -1369,6 +1571,7 @@ fn prepare_list( return Err(ProblemCode::FieldsInvalid); } } + "representation" => {} _ if declared.contains(name.as_str()) => { if raw_filters.insert(name, value).is_some() { return Err(ProblemCode::InvalidFilter); @@ -1399,7 +1602,12 @@ fn prepare_list( } } } - let selected_fields = fields_from_text(resource, operation, fields_text.as_deref())?; + let selected_fields = fields_from_text( + resource, + operation, + &access.representation, + fields_text.as_deref(), + )?; Ok(PreparedList { page_size, filters, @@ -1411,23 +1619,28 @@ fn prepare_list( fn selected_fields( resource: &CompiledResource, operation: &CompiledOperation, + representation: &CompiledRepresentation, query: Option<&str>, ) -> Result, ProblemCode> { let parameters = parse_query(query)?; - if parameters.iter().any(|(name, _)| name != "fields") { + if parameters + .iter() + .any(|(name, _)| name != "fields" && name != "representation") + { return Err(ProblemCode::ConsultationInvalidRequest); } let fields = one_parameter(¶meters, "fields")?; - fields_from_text(resource, operation, fields) + fields_from_text(resource, operation, representation, fields) } fn fields_from_text( resource: &CompiledResource, - operation: &CompiledOperation, + _operation: &CompiledOperation, + representation: &CompiledRepresentation, text: Option<&str>, ) -> Result, ProblemCode> { let Some(text) = text else { - return Ok(operation.selectable_properties.clone()); + return Ok(representation.selectable_properties.clone()); }; if text.is_empty() || text.bytes().any(|byte| byte.is_ascii_whitespace()) { return Err(ProblemCode::FieldsInvalid); @@ -1439,7 +1652,7 @@ fn fields_from_text( { return Err(ProblemCode::FieldsInvalid); } - let allowed = operation + let allowed = representation .selectable_properties .iter() .map(String::as_str) @@ -1453,7 +1666,7 @@ fn fields_from_text( }) { return Err(ProblemCode::FieldsInvalid); } - Ok(operation + Ok(representation .selectable_properties .iter() .filter(|field| requested.contains(&field.as_str())) @@ -1464,13 +1677,15 @@ fn fields_from_text( fn validate_selected_inventory( resource: &CompiledResource, operation: &CompiledOperation, + representation: &CompiledRepresentation, fields: &[String], ) -> Result<(), ProblemCode> { if fields.is_empty() { return Err(ProblemCode::CursorInvalid); } let text = fields.join(","); - let canonical = fields_from_text(resource, operation, Some(&text))?; + let canonical = fields_from_text(resource, operation, representation, Some(&text)) + .map_err(|_| ProblemCode::CursorInvalid)?; if canonical != fields { return Err(ProblemCode::CursorInvalid); } @@ -1562,6 +1777,9 @@ fn parse_text_value(value: &str, data_type: DataType) -> Option { DataType::DateTime => DateTime::parse_from_rfc3339(value) .ok() .map(|_| SqlValue::String(value.to_owned())), + DataType::Year => (value.len() == 4 && value.bytes().all(|byte| byte.is_ascii_digit())) + .then(|| SqlValue::String(value.to_owned())), + DataType::YearMonth => valid_year_month(value).then(|| SqlValue::String(value.to_owned())), } } @@ -1633,25 +1851,52 @@ fn json_scalar_to_sql(value: &Value, data_type: DataType) -> Option { .ok() .map(|_| SqlValue::String(value.to_owned())) }), + DataType::Year => value + .as_str() + .filter(|value| value.len() == 4 && value.bytes().all(|byte| byte.is_ascii_digit())) + .map(|value| SqlValue::String(value.to_owned())), + DataType::YearMonth => value + .as_str() + .filter(|value| valid_year_month(value)) + .map(|value| SqlValue::String(value.to_owned())), } } +fn valid_year_month(value: &str) -> bool { + value.len() == 7 + && value.as_bytes().get(4) == Some(&b'-') + && value.bytes().take(4).all(|byte| byte.is_ascii_digit()) + && matches!( + &value[5..], + "01" | "02" | "03" | "04" | "05" | "06" | "07" | "08" | "09" | "10" | "11" | "12" + ) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum RecordError { + InvalidCore, + InvalidSource, +} + fn record_value( service: &RelayService, resource: &CompiledResource, - _operation: &CompiledOperation, + representation: &CompiledRepresentation, row: &ResultRow, selected: &[String], -) -> Option { - let record_identifier = - required_string(row, &resource.record_context.record_identifier_column)?; +) -> Result { + let record_identifier = required_string(row, &resource.record_context.record_identifier_column) + .ok_or(RecordError::InvalidCore)?; if !valid_record_identifier(record_identifier) { - return None; - } - let revision = required_string(row, &resource.record_context.revision_identifier_column)?; - let lifecycle = required_string(row, &resource.record_context.lifecycle_state_column)?; - let recorded_at = required_string(row, &resource.record_context.recorded_at_column)?; - DateTime::parse_from_rfc3339(recorded_at).ok()?; + return Err(RecordError::InvalidCore); + } + let revision = required_string(row, &resource.record_context.revision_identifier_column) + .ok_or(RecordError::InvalidCore)?; + let lifecycle = required_string(row, &resource.record_context.lifecycle_state_column) + .ok_or(RecordError::InvalidCore)?; + let recorded_at = required_string(row, &resource.record_context.recorded_at_column) + .ok_or(RecordError::InvalidCore)?; + DateTime::parse_from_rfc3339(recorded_at).map_err(|_| RecordError::InvalidCore)?; if revision.is_empty() || lifecycle.is_empty() || !codelist_accepts( @@ -1660,43 +1905,73 @@ fn record_value( lifecycle, ) { - return None; + return Err(RecordError::InvalidCore); } - // Validate the complete reviewed source projection before narrowing. - for property in &resource.properties { - let value = row.get(&property.source_column)?; + // Validate the complete selected representation before requester field + // minimization. Narrowing disclosure never lowers its processing floor. + let properties = representation + .selectable_properties + .iter() + .map(|name| { + resource + .properties + .iter() + .find(|property| property.name == *name) + .ok_or(RecordError::InvalidSource) + }) + .collect::, _>>()?; + let mut transformed = BTreeMap::new(); + for property in &properties { + let source = row + .get(&property.source_column) + .ok_or(RecordError::InvalidSource)?; + if matches!(source, SqlValue::Null) { + if property.source_required { + return Err(RecordError::InvalidSource); + } + continue; + } + let value = match &property.transform { + Some(compiled) => { + transform::apply(compiled, source).map_err(|_| RecordError::InvalidSource)? + } + None => source.clone(), + }; if matches!(value, SqlValue::Null) { if property.source_required { - return None; + return Err(RecordError::InvalidSource); } continue; } if !valid_property_value( service, - value, + &value, property.data_type, property.codelist.as_deref(), ) { - return None; + return Err(RecordError::InvalidSource); } + transformed.insert(property.name.as_str(), value); } let mut domain = Map::new(); - for property in &resource.properties { + for property in properties { if !selected.contains(&property.name) { continue; } - let value = row.get(&property.source_column)?; - if !matches!(value, SqlValue::Null) { - domain.insert(property.name.clone(), sql_to_json(value.clone())?); + if let Some(value) = transformed.remove(property.name.as_str()) { + domain.insert( + property.name.clone(), + sql_to_json(value).ok_or(RecordError::InvalidSource)?, + ); } } - Some(json!({ + Ok(json!({ "registryIdentifier": service.registry.registry_identifier, "recordIdentifier": record_identifier, "revisionIdentifier": revision, "lifecycleState": lifecycle, - "schemaReference": _operation.schema_reference, - "semanticModelReference": _operation.semantic_model_reference, + "schemaReference": representation.schema_reference, + "semanticModelReference": representation.semantic_model_reference, "authorityIdentifier": service.registry.authority_identifier, "recordedAt": recorded_at, "domainData": domain, @@ -1720,6 +1995,10 @@ fn valid_property_value( (SqlValue::String(value), DataType::DateTime) => { DateTime::parse_from_rfc3339(value).is_ok() } + (SqlValue::String(value), DataType::Year) => { + value.len() == 4 && value.bytes().all(|byte| byte.is_ascii_digit()) + } + (SqlValue::String(value), DataType::YearMonth) => valid_year_month(value), (SqlValue::Boolean(_), DataType::Boolean) | (SqlValue::Integer(_), DataType::Integer) => { true } @@ -1761,23 +2040,25 @@ fn record_meta( service: &RelayService, resource: &CompiledResource, operation: &CompiledOperation, + representation: &CompiledRepresentation, selected: &[String], source_revision: &SourceRevision, ) -> Value { let pattern = operation_pattern(&operation.kind); json!({ "operationIdentifier": operation.identifier, + "representation": representation.id, "family": "consultation", "pattern": pattern, - "disclosureProfile": operation.disclosure_profile, + "disclosureProfile": representation.disclosure_profile, "contractRevision": service.registry.contract_revision, "sourceRevision": source_revision_value(source_revision), "selectedFields": selected, "links": { "self": operation_href(service, resource, operation), - "context": operation.context_reference, - "schema": operation.schema_reference, - "semanticModel": operation.semantic_model_reference, + "context": representation.context_reference, + "schema": representation.schema_reference, + "semanticModel": representation.semantic_model_reference, } }) } @@ -1796,14 +2077,14 @@ fn source_revision_value(source: &SourceRevision) -> Value { fn apply_json_ld( service: &RelayService, resource: &CompiledResource, - operation: &CompiledOperation, - representation: Representation, + selected: &CompiledRepresentation, + representation: ResponseFormat, document: &mut Value, ) { - if representation != Representation::JsonLd { + if representation != ResponseFormat::JsonLd { return; } - let context = operation.context_reference.clone(); + let context = selected.context_reference.clone(); if let Some(object) = document.as_object_mut() { object.insert("@context".into(), Value::String(context)); if let Some(data) = object.get_mut("data") { @@ -1840,7 +2121,7 @@ async fn release_document( service: &RelayService, audit: &AuditContext, document: Value, - representation: Representation, + representation: ResponseFormat, cacheable: bool, headers: &HeaderMap, trace: &TraceContext, @@ -1929,11 +2210,12 @@ async fn source_failure( ) -> Response { let (outcome, code) = match error { SqliteRuntimeError::AdmissionTimeout => (AuditOutcome::TimedOut, ProblemCode::Timeout), + SqliteRuntimeError::UnknownOperation | SqliteRuntimeError::InvalidPlan => { + (AuditOutcome::InternalFailed, ProblemCode::Internal) + } SqliteRuntimeError::MissingSource - | SqliteRuntimeError::UnknownOperation | SqliteRuntimeError::SchemaMismatch - | SqliteRuntimeError::InvalidPlan => (AuditOutcome::InternalFailed, ProblemCode::Internal), - SqliteRuntimeError::Source(_) => { + | SqliteRuntimeError::Source(_) => { (AuditOutcome::SourceFailed, ProblemCode::SourceUnavailable) } }; @@ -1943,6 +2225,21 @@ async fn source_failure( code.response(trace) } +async fn source_shape_failure( + audit: &RelayAudit, + context: &AuditContext, + trace: &TraceContext, +) -> Response { + if audit + .terminal(context, AuditOutcome::SourceFailed, None) + .await + .is_err() + { + return ProblemCode::AuditUnavailable.response(trace); + } + ProblemCode::SourceUnavailable.response(trace) +} + async fn terminal_problem( audit: &RelayAudit, context: &AuditContext, @@ -1956,14 +2253,15 @@ async fn terminal_problem( code.response(trace) } -fn cacheable(operation: &CompiledOperation, source: &SourceRevision) -> bool { - matches!(operation.access, CompiledAccess::Public) +fn cacheable(representation: &CompiledRepresentation, source: &SourceRevision) -> bool { + matches!(representation.access, CompiledAccess::Public) + && representation.processing_handling == Handling::Public && matches!(source, SourceRevision::Snapshot(_)) } -fn negotiate(headers: &HeaderMap) -> Result { +fn negotiate(headers: &HeaderMap) -> Result { let Some(value) = headers.get(ACCEPT) else { - return Ok(Representation::Json); + return Ok(ResponseFormat::Json); }; let value = value .to_str() @@ -1984,9 +2282,9 @@ fn negotiate(headers: &HeaderMap) -> Result { } } if json_ld { - Ok(Representation::JsonLd) + Ok(ResponseFormat::JsonLd) } else if json { - Ok(Representation::Json) + Ok(ResponseFormat::Json) } else { Err(ProblemCode::UnsupportedRepresentation) } @@ -2053,6 +2351,12 @@ fn next_cursor( encode_cursor(key, &payload).map_err(|_| ()) } +fn valid_cursor_order_values(order_by: &[String], row: &ResultRow) -> bool { + order_by + .iter() + .all(|column| row.get(column).cloned().and_then(sql_to_cursor).is_some()) +} + fn cursor_template( service: &RelayService, operation: &CompiledOperation, @@ -2069,10 +2373,14 @@ fn cursor_template( let field_json = serde_json::to_vec(selected_fields).map_err(|_| ProblemCode::CursorInvalid)?; let order_json = serde_json::to_vec(&operation.query.order_by).map_err(|_| ProblemCode::CursorInvalid)?; + let transform_json = serde_json::to_vec(&access.representation.transform_inventory) + .map_err(|_| ProblemCode::CursorInvalid)?; let authorization_material = access .principal .as_ref() - .map(|principal| principal.authorization_material(&operation.access, &access.authorization)) + .map(|principal| { + principal.authorization_material(&access.representation.access, &access.authorization) + }) .unwrap_or_else(|| b"anonymous".to_vec()); Ok(CursorPayload::new( u64::MAX, @@ -2080,6 +2388,11 @@ fn cursor_template( source_revision.to_owned(), operation.identifier.clone(), CursorBindings { + representation: access.representation.id.clone(), + disclosure_profile: access.representation.disclosure_profile.clone(), + transforms_digest: key + .binding_digest(b"transforms", &transform_json) + .map_err(|_| ProblemCode::CursorInvalid)?, filters_digest: key .binding_digest(b"filters", &filter_json) .map_err(|_| ProblemCode::CursorInvalid)?, @@ -2099,7 +2412,7 @@ fn cursor_template( fn metadata_cursor_template( service: &RelayService, - visible: &[(&CompiledResource, Vec<&CompiledOperation>)], + visible: &[(&CompiledResource, Vec>)], ) -> Result { let key = service .cursor_key @@ -2112,7 +2425,9 @@ fn metadata_cursor_template( resource.id.as_str(), operations .iter() - .map(|operation| operation.identifier.as_str()) + .map(|(operation, representation)| { + (operation.identifier.as_str(), representation.id.as_str()) + }) .collect::>(), ) }) @@ -2125,6 +2440,11 @@ fn metadata_cursor_template( format!("metadata:{}", service.registry.contract_revision), "registry.resources".to_owned(), CursorBindings { + representation: "metadata".to_owned(), + disclosure_profile: "metadata".to_owned(), + transforms_digest: key + .binding_digest(b"metadata-transforms", b"none") + .map_err(|_| ProblemCode::CursorInvalid)?, filters_digest: key .binding_digest(b"metadata-filters", b"none") .map_err(|_| ProblemCode::CursorInvalid)?, @@ -2144,7 +2464,7 @@ fn metadata_cursor_template( fn metadata_next_cursor( service: &RelayService, - visible: &[(&CompiledResource, Vec<&CompiledOperation>)], + visible: &[(&CompiledResource, Vec>)], page_size: usize, last_resource_identifier: &str, ) -> Result { @@ -2210,7 +2530,7 @@ fn find_operation_by_id<'a>( async fn visible_resources<'a>( service: &'a RelayService, principal: Option<&Principal>, -) -> Result)>, ProblemCode> { +) -> Result>)>, ProblemCode> { if service.registry.metadata_visibility.resources == Visibility::OperatorOnly { return Err(ProblemCode::ResourceNotFound); } @@ -2233,13 +2553,21 @@ async fn visible_operations<'a>( service: &'a RelayService, resource: &'a CompiledResource, principal: Option<&Principal>, -) -> Result, ProblemCode> { +) -> Result>, ProblemCode> { match service.registry.metadata_visibility.resources { Visibility::OperatorOnly => Ok(Vec::new()), Visibility::Public => Ok(resource .operations .iter() - .filter(|operation| matches!(operation.access, CompiledAccess::Public)) + .flat_map(|operation| { + operation + .representations + .iter() + .filter(|representation| { + matches!(representation.access, CompiledAccess::Public) + }) + .map(move |representation| (operation, representation)) + }) .collect()), Visibility::OperationBound => { let principal = principal.ok_or(ProblemCode::MissingCredential)?; @@ -2250,10 +2578,16 @@ async fn visible_operations<'a>( Ok(resource .operations .iter() - .filter(|operation| { - authenticator - .authorize(&operation.access, Some(principal)) - .is_ok() + .flat_map(|operation| { + operation + .representations + .iter() + .filter_map(move |representation| { + authenticator + .authorize(&representation.access, Some(principal)) + .is_ok() + .then_some((operation, representation)) + }) }) .collect()) } @@ -2268,18 +2602,20 @@ fn protected_artifact(artifact: &GeneratedArtifact) -> bool { artifact.visibility == Visibility::OperationBound } +type VisibleRepresentation<'a> = (&'a CompiledOperation, &'a CompiledRepresentation); + fn resource_document( service: &RelayService, resource: &CompiledResource, - operations: &[&CompiledOperation], + operations: &[VisibleRepresentation<'_>], ) -> Value { let enumeration = if operations .iter() - .any(|operation| matches!(operation.kind, OperationKind::List)) + .any(|(operation, _)| matches!(operation.kind, OperationKind::List)) { - if operations.iter().any(|operation| { + if operations.iter().any(|(operation, representation)| { matches!(operation.kind, OperationKind::List) - && matches!(operation.access, CompiledAccess::Public) + && matches!(representation.access, CompiledAccess::Public) }) { "public" } else { @@ -2294,7 +2630,7 @@ fn resource_document( "description": resource.description, "semanticClass": resource.semantic_class, "enumerationPosture": enumeration, - "capabilities": operations.iter().map(|operation| capability(service, resource, operation)).collect::>(), + "capabilities": operations.iter().map(|(operation, representation)| capability(service, resource, operation, representation)).collect::>(), "links": { "self": absolute(&service.registry.base_uri, &format!("/v2/resources/{}", resource.id)), } @@ -2305,22 +2641,30 @@ fn capability( service: &RelayService, resource: &CompiledResource, operation: &CompiledOperation, + representation: &CompiledRepresentation, ) -> Value { let mut document = json!({ "family": "consultation", "pattern": operation_pattern(&operation.kind), "resourceIdentifier": resource.id, "operationIdentifier": operation.identifier, - "schemaReference": operation.schema_reference, - "semanticModelReference": operation.semantic_model_reference, - "contextReference": operation.context_reference, + "representation": representation.id, + "defaultRepresentation": operation.default_representation == representation.id, + "disclosureProfile": representation.disclosure_profile, + "schemaReference": representation.schema_reference, + "semanticModelReference": representation.semantic_model_reference, + "contextReference": representation.context_reference, "href": match &operation.kind { OperationKind::List => format!("/v2/resources/{}/records", resource.id), OperationKind::Read => format!("/v2/resources/{}/records/{{recordIdentifier}}", resource.id), OperationKind::Lookup {name} => format!("/v2/resources/{}/lookups/{name}", resource.id), } }); - let stem = operation_artifact_stem(&resource.id, &operation.kind); + let stem = format!( + "{}--representation-{}", + operation_artifact_stem(&resource.id, &operation.kind), + representation.id + ); let object = document .as_object_mut() .expect("capability document is an object"); @@ -2328,7 +2672,7 @@ fn capability( object.insert( "classificationReference".into(), Value::String(sibling_artifact_reference( - &operation.schema_reference, + &representation.schema_reference, &format!("{stem}-classifications"), )), ); @@ -2337,7 +2681,7 @@ fn capability( object.insert( "processingReference".into(), Value::String(sibling_artifact_reference( - &operation.schema_reference, + &representation.schema_reference, &format!("{stem}-processing"), )), ); @@ -2503,6 +2847,27 @@ fn if_none_match(headers: &HeaderMap, etag: &str) -> bool { mod tests { use super::*; + #[test] + fn cursor_order_values_must_be_present_non_null_supported_scalars() { + let order = vec!["rank".to_owned(), "record_id".to_owned()]; + let valid = BTreeMap::from([ + ("rank".to_owned(), SqlValue::Integer(7)), + ( + "record_id".to_owned(), + SqlValue::String("record-7".to_owned()), + ), + ]); + assert!(valid_cursor_order_values(&order, &valid)); + + let mut null = valid.clone(); + null.insert("rank".to_owned(), SqlValue::Null); + assert!(!valid_cursor_order_values(&order, &null)); + + let mut unsupported = valid; + unsupported.insert("rank".to_owned(), SqlValue::Number(7.5)); + assert!(!valid_cursor_order_values(&order, &unsupported)); + } + #[test] fn serialized_response_ceiling_counts_exact_json_bytes() { let document = json!({"escaped": "\n\n\n\n"}); diff --git a/crates/registry-relay-v2/src/artifacts.rs b/crates/registry-relay-v2/src/artifacts.rs index a83acb742..9d9e1a3f1 100644 --- a/crates/registry-relay-v2/src/artifacts.rs +++ b/crates/registry-relay-v2/src/artifacts.rs @@ -40,6 +40,9 @@ pub struct GeneratedArtifact { /// Present only for operation-bound artifacts. The HTTP layer must mount /// the artifact behind this exact compiled operation's static access gate. pub operation_identifier: Option, + /// Present with `operation_identifier` when an operation-bound artifact + /// belongs to one exact finite representation. + pub representation_identifier: Option, pub sha256: String, pub content: Vec, } @@ -48,6 +51,7 @@ pub struct GeneratedArtifact { #[serde(rename_all = "camelCase")] pub struct OperationArtifactBindings { pub operation_identifier: String, + pub representation_identifier: String, pub vocabulary_path: String, pub context_path: String, pub representation_schema_path: String, @@ -180,135 +184,154 @@ pub fn generate_artifacts(registry: &CompiledRegistry) -> Result>(), + }), + )?; + let processing_visibility = projection_visibility( + registry.metadata_visibility.processing, + &representation.access, + ); + let operation_ref = operation_contract_reference(&operation.kind); + push_representation_json( + &mut artifacts, + &format!("{suffix}-processing"), + &processing_path, + "application/json", + processing_visibility, + &operation.identifier, + &representation.id, + &json!({ + "resourceIdentifier": resource.id, + "operationIdentifier": operation.identifier, + "representationIdentifier": representation.id, + "processingHandling": representation.processing_handling, + "disclosureHandling": representation.disclosure_handling, + "transformIdentifiers": representation.transform_inventory, + "descriptions": resource.processing_descriptions.iter() + .filter(|description| description.operation_refs.contains(&operation_ref)) + .collect::>(), + }), + )?; + bindings.push(OperationArtifactBindings { + operation_identifier: operation.identifier.clone(), + representation_identifier: representation.id.clone(), + vocabulary_path, + context_path, + representation_schema_path: schema_path, + representation_shacl_path: shacl_path, + classification_path, + processing_path, + }); } - let disclosure = resource - .disclosure_profiles - .iter() - .find(|profile| profile.id == operation.disclosure_profile) - .ok_or(ArtifactError::MissingDisclosure)?; - let semantic_visibility = - projection_visibility(registry.metadata_visibility.semantics, &operation.access); - let semantic_operation_identifier = (semantic_visibility == Visibility::OperationBound) - .then(|| operation.identifier.clone()); - let suffix = operation_artifact_stem(&resource.id, &operation.kind); - let vocabulary_path = format!("artifacts/{suffix}.vocabulary.jsonld"); - let context_path = format!("artifacts/{suffix}.context.jsonld"); - let schema_path = format!("artifacts/{suffix}.schema.json"); - let shacl_path = format!("artifacts/{suffix}.shacl.ttl"); - let classification_path = format!("artifacts/{suffix}.classifications.json"); - let processing_path = format!("artifacts/{suffix}.processing.json"); - push_json( - &mut artifacts, - &format!("{suffix}-vocabulary"), - &vocabulary_path, - "application/ld+json", - semantic_visibility, - semantic_operation_identifier.clone(), - &local_vocabulary(registry, resource, &disclosure.properties), - )?; - push_json( - &mut artifacts, - &format!("{suffix}-context"), - &context_path, - "application/ld+json", - semantic_visibility, - semantic_operation_identifier.clone(), - &json_ld_context(registry, resource, &disclosure.properties), - )?; - push_json( - &mut artifacts, - &format!("{suffix}-schema"), - &schema_path, - "application/schema+json", - semantic_visibility, - semantic_operation_identifier.clone(), - &representation_schema( - registry, - resource, - &disclosure.properties, - &operation.schema_reference, - &operation.semantic_model_reference, - ), - )?; - push_text( - &mut artifacts, - &format!("{suffix}-shacl"), - &shacl_path, - "text/turtle", - semantic_visibility, - semantic_operation_identifier, - representation_shacl(registry, resource, &disclosure.properties).into_bytes(), - ); - let classification_visibility = projection_visibility( - registry.metadata_visibility.classifications, - &operation.access, - ); - push_json( - &mut artifacts, - &format!("{suffix}-classifications"), - &classification_path, - "application/json", - classification_visibility, - (classification_visibility == Visibility::OperationBound) - .then(|| operation.identifier.clone()), - &json!({ - "resourceIdentifier": resource.id, - "operationIdentifier": operation.identifier, - "properties": resource.properties.iter() - .filter(|property| disclosure.properties.contains(&property.name)) - .map(|property| json!({ - "property": property.name, - "classification": property.classification, - })) - .collect::>(), - }), - )?; - let processing_visibility = - projection_visibility(registry.metadata_visibility.processing, &operation.access); - let operation_ref = operation_contract_reference(&operation.kind); - push_json( - &mut artifacts, - &format!("{suffix}-processing"), - &processing_path, - "application/json", - processing_visibility, - (processing_visibility == Visibility::OperationBound) - .then(|| operation.identifier.clone()), - &json!({ - "resourceIdentifier": resource.id, - "operationIdentifier": operation.identifier, - "descriptions": resource.processing_descriptions.iter() - .filter(|description| description.operation_refs.contains(&operation_ref)) - .collect::>(), - }), - )?; - bindings.push(OperationArtifactBindings { - operation_identifier: operation.identifier.clone(), - vocabulary_path, - context_path, - representation_schema_path: schema_path, - representation_shacl_path: shacl_path, - classification_path, - processing_path, - }); } let codelists = resource @@ -339,7 +362,14 @@ pub fn generate_artifacts(registry: &CompiledRegistry) -> Result Result Visibility { match configured { - Visibility::Public => Visibility::Public, Visibility::OperatorOnly => Visibility::OperatorOnly, - Visibility::OperationBound => match access { + Visibility::Public | Visibility::OperationBound => match access { CompiledAccess::Public => Visibility::Public, CompiledAccess::Protected { .. } => Visibility::OperationBound, }, @@ -374,6 +403,17 @@ fn operation_artifact_stem(resource: &str, kind: &OperationKind) -> String { } } +fn representation_artifact_stem( + resource: &str, + kind: &OperationKind, + representation: &str, +) -> String { + format!( + "{}--representation-{representation}", + operation_artifact_stem(resource, kind) + ) +} + #[allow(clippy::too_many_arguments)] fn push_json( artifacts: &mut Vec, @@ -397,6 +437,61 @@ fn push_json( Ok(()) } +#[allow(clippy::too_many_arguments)] +fn push_representation_json( + artifacts: &mut Vec, + id: &str, + path: &str, + media_type: &str, + visibility: Visibility, + operation_identifier: &str, + representation_identifier: &str, + value: &Value, +) -> Result<(), ArtifactError> { + let bound = visibility == Visibility::OperationBound; + push_json( + artifacts, + id, + path, + media_type, + visibility, + bound.then(|| operation_identifier.to_owned()), + value, + )?; + artifacts + .last_mut() + .expect("a representation artifact was appended") + .representation_identifier = bound.then(|| representation_identifier.to_owned()); + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +fn push_representation_text( + artifacts: &mut Vec, + id: &str, + path: &str, + media_type: &str, + visibility: Visibility, + operation_identifier: &str, + representation_identifier: &str, + content: Vec, +) { + let bound = visibility == Visibility::OperationBound; + push_text( + artifacts, + id, + path, + media_type, + visibility, + bound.then(|| operation_identifier.to_owned()), + content, + ); + artifacts + .last_mut() + .expect("a representation artifact was appended") + .representation_identifier = bound.then(|| representation_identifier.to_owned()); +} + #[allow(clippy::too_many_arguments)] fn push_text( artifacts: &mut Vec, @@ -413,6 +508,7 @@ fn push_text( media_type: media_type.into(), visibility, operation_identifier, + representation_identifier: None, sha256: format!("sha256:{}", hex::encode(Sha256::digest(&content))), content, }); @@ -472,7 +568,14 @@ fn openapi(registry: &CompiledRegistry, public_only: bool) -> Value { } for resource in ®istry.resources { for operation in &resource.operations { - if public_only && !matches!(operation.access, CompiledAccess::Public) { + let visible_representations = operation + .representations + .iter() + .filter(|representation| { + !public_only || matches!(&representation.access, CompiledAccess::Public) + }) + .collect::>(); + if visible_representations.is_empty() { continue; } let (method, path, pattern) = match &operation.kind { @@ -492,19 +595,51 @@ fn openapi(registry: &CompiledRegistry, public_only: bool) -> Value { "search", ), }; - let security = match &operation.access { - CompiledAccess::Public => json!([]), - CompiledAccess::Protected { .. } => { - json!([{"bearerAuth": []}]) - } + let has_public = visible_representations + .iter() + .any(|representation| matches!(&representation.access, CompiledAccess::Public)); + let has_protected = visible_representations.iter().any(|representation| { + matches!(&representation.access, CompiledAccess::Protected { .. }) + }); + let security = match (has_public, has_protected) { + (true, true) => json!([{}, {"bearerAuth": []}]), + (true, false) => json!([]), + (false, true) => json!([{"bearerAuth": []}]), + (false, false) => unreachable!("a visible representation exists"), }; - let mut parameters = vec![json!({ - "name": "fields", - "in": "query", - "required": false, - "schema": {"type": "string", "minLength": 1}, - "description": "Duplicate-free comma-separated subset of the operation disclosure profile" - })]; + let visible_identifiers = visible_representations + .iter() + .map(|representation| representation.id.clone()) + .collect::>(); + let visible_default = visible_identifiers + .contains(&operation.default_representation) + .then(|| operation.default_representation.clone()); + let mut representation_schema = json!({ + "type": "string", + "enum": visible_identifiers, + }); + if let Some(default) = &visible_default { + representation_schema + .as_object_mut() + .expect("representation schema object") + .insert("default".into(), json!(default)); + } + let mut parameters = vec![ + json!({ + "name": "representation", + "in": "query", + "required": false, + "schema": representation_schema, + "description": "One finite compiled representation. Absence selects the declared default." + }), + json!({ + "name": "fields", + "in": "query", + "required": false, + "schema": {"type": "string", "minLength": 1}, + "description": "Duplicate-free comma-separated subset of the selected representation" + }), + ]; match &operation.kind { OperationKind::List => { let pagination = operation @@ -534,25 +669,45 @@ fn openapi(registry: &CompiledRegistry, public_only: bool) -> Value { "operationId": operation.identifier, "x-registry-family": "consultation", "x-registry-pattern": pattern, - "x-registry-disclosure-profile": operation.disclosure_profile, + "x-registry-representations": visible_representations.iter().map(|representation| json!({ + "identifier": representation.id, + "default": operation.default_representation == representation.id, + "disclosureProfile": representation.disclosure_profile, + "processingHandling": representation.processing_handling, + "disclosureHandling": representation.disclosure_handling, + "transformIdentifiers": representation.transform_inventory, + "schemaReference": representation.schema_reference, + "semanticModelReference": representation.semantic_model_reference, + "contextReference": representation.context_reference, + })).collect::>(), "security": security, "parameters": parameters, "responses": { "200": { "description": "A validated minimum-disclosure Registry response", "content": { - "application/json": {"schema": operation_response_schema(operation)}, - "application/ld+json": {"schema": operation_response_schema(operation)} + "application/json": {"schema": operation_response_schema(operation, &visible_representations)}, + "application/ld+json": {"schema": operation_response_schema(operation, &visible_representations)} } }, "default": {"$ref": "#/components/responses/Problem"} } }); - if let CompiledAccess::Protected { scope, .. } = &operation.access { + let required_scopes = visible_representations + .iter() + .filter_map(|representation| match &representation.access { + CompiledAccess::Public => None, + CompiledAccess::Protected { scope, .. } => Some(json!({ + "representation": representation.id, + "scope": scope, + })), + }) + .collect::>(); + if !required_scopes.is_empty() { operation_value .as_object_mut() .expect("operation object") - .insert("x-registry-required-scope".into(), json!(scope)); + .insert("x-registry-required-scopes".into(), json!(required_scopes)); } if matches!(&operation.kind, OperationKind::Lookup { .. }) { let mut selector_properties = Map::new(); @@ -642,14 +797,26 @@ fn openapi(registry: &CompiledRegistry, public_only: bool) -> Value { }) } -fn operation_response_schema(operation: &crate::model::CompiledOperation) -> Value { +fn operation_response_schema( + operation: &crate::model::CompiledOperation, + representations: &[&crate::model::CompiledRepresentation], +) -> Value { let meta = json!({"type": "object"}); + let record = if representations.len() == 1 { + json!({"$ref": representations[0].schema_reference}) + } else { + json!({ + "oneOf": representations.iter().map(|representation| { + json!({"$ref": representation.schema_reference}) + }).collect::>() + }) + }; match &operation.kind { OperationKind::List => json!({ "type": "object", "additionalProperties": false, "required": ["items", "pageInfo", "meta"], "properties": { - "items": {"type": "array", "items": {"$ref": operation.schema_reference}}, + "items": {"type": "array", "items": record}, "pageInfo": { "type": "object", "additionalProperties": false, "required": ["nextCursor"], @@ -662,7 +829,7 @@ fn operation_response_schema(operation: &crate::model::CompiledOperation) -> Val "type": "object", "additionalProperties": false, "required": ["data", "meta"], "properties": { - "data": {"$ref": operation.schema_reference}, + "data": record, "meta": meta } }), @@ -677,6 +844,16 @@ fn openapi_type(data_type: crate::contract::DataType) -> Value { DataType::Integer => json!({"type": "integer"}), DataType::Date => json!({"type": "string", "format": "date"}), DataType::DateTime => json!({"type": "string", "format": "date-time"}), + DataType::Year => json!({ + "type": "string", + "pattern": "^[0-9]{4}$", + "x-registry-datatype": "year" + }), + DataType::YearMonth => json!({ + "type": "string", + "pattern": "^[0-9]{4}-(0[1-9]|1[0-2])$", + "x-registry-datatype": "year-month" + }), } } @@ -684,7 +861,7 @@ fn openapi_type(data_type: crate::contract::DataType) -> Value { enum CapabilityProjection<'a> { Public, Full, - Operation(&'a str), + Representation(&'a str, &'a str), } fn capability_inventory( @@ -695,34 +872,42 @@ fn capability_inventory( .resources .iter() .flat_map(|resource| { - resource.operations.iter().filter_map(move |operation| { - let include = match projection { - CapabilityProjection::Public => { - matches!(&operation.access, CompiledAccess::Public) - } - CapabilityProjection::Full => true, - CapabilityProjection::Operation(identifier) => { - operation.identifier == identifier + resource.operations.iter().flat_map(move |operation| { + operation.representations.iter().filter_map(move |representation| { + let include = match projection { + CapabilityProjection::Public => { + matches!(&representation.access, CompiledAccess::Public) + } + CapabilityProjection::Full => true, + CapabilityProjection::Representation( + operation_identifier, + representation_identifier, + ) => { + operation.identifier == operation_identifier + && representation.id == representation_identifier + } + }; + if !include { + return None; } - }; - if !include { - return None; - } - let pattern = match &operation.kind { - OperationKind::List => "list", - OperationKind::Read => "retrieve", - OperationKind::Lookup { .. } => "search", - }; - Some(json!({ - "resource": resource.id, - "operationIdentifier": operation.identifier, - "family": "consultation", - "pattern": pattern, - "profile": if matches!(&operation.kind, OperationKind::Lookup { .. }) { Value::String("exact".into()) } else { Value::Null }, - "schemaReference": operation.schema_reference, - "semanticModelReference": operation.semantic_model_reference, - "contextReference": operation.context_reference, - })) + let pattern = match &operation.kind { + OperationKind::List => "list", + OperationKind::Read => "retrieve", + OperationKind::Lookup { .. } => "search", + }; + Some(json!({ + "resource": resource.id, + "operationIdentifier": operation.identifier, + "representationIdentifier": representation.id, + "defaultRepresentation": operation.default_representation == representation.id, + "family": "consultation", + "pattern": pattern, + "profile": if matches!(&operation.kind, OperationKind::Lookup { .. }) { Value::String("exact".into()) } else { Value::Null }, + "schemaReference": representation.schema_reference, + "semanticModelReference": representation.semantic_model_reference, + "contextReference": representation.context_reference, + })) + }) }) }) .collect::>(); @@ -748,7 +933,7 @@ fn audit_event_schema() -> Value { "required": [ "schema", "phase", "operationId", "traceId", "registryIdentifier", "rowBoundaryKind", "processingDescriptionIdentifiers", "selectedProperties", - "contractRevision", "principalKind" + "transformIdentifiers", "contractRevision", "principalKind" ], "properties": { "schema": {"const": crate::audit::AUDIT_SCHEMA}, @@ -761,10 +946,13 @@ fn audit_event_schema() -> Value { "accessRuleRevision": {"type": "string", "minLength": 1}, "purpose": {"type": "string", "minLength": 1}, "rowBoundaryKind": {"enum": ["none", "principal", "verified-claim", "unknown"]}, + "representation": {"type": "string", "minLength": 1}, "disclosureProfile": {"type": "string", "minLength": 1}, "processingDescriptionIdentifiers": {"type": "array", "items": {"type": "string", "minLength": 1}, "uniqueItems": true}, "selectedProperties": {"type": "array", "items": {"type": "string", "minLength": 1}, "uniqueItems": true}, - "maximumHandling": {"enum": ["public", "internal", "confidential", "restricted"]}, + "processingHandling": {"enum": ["public", "internal", "confidential", "restricted"]}, + "disclosureHandling": {"enum": ["public", "internal", "confidential", "restricted"]}, + "transformIdentifiers": {"type": "array", "items": {"type": "string", "minLength": 1}, "uniqueItems": true}, "contractRevision": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, "sourceRevision": { "type": "object", @@ -820,10 +1008,10 @@ mod tests { "artifacts/record.full.schema.json", "artifacts/record.full.shacl.ttl", "artifacts/record.full.vocabulary.jsonld", - "artifacts/record--read.schema.json", - "artifacts/record--read.shacl.ttl", - "artifacts/record--read.context.jsonld", - "artifacts/record--read.vocabulary.jsonld", + "artifacts/record--read--representation-public.schema.json", + "artifacts/record--read--representation-public.shacl.ttl", + "artifacts/record--read--representation-public.context.jsonld", + "artifacts/record--read--representation-public.vocabulary.jsonld", ] { assert!(paths.contains(required), "missing {required}"); } @@ -916,7 +1104,7 @@ mod tests { &compiler_tests::governed_files(), ) .expect("contract compiles"); - registry.resources[0].operations[0].access = CompiledAccess::Protected { + registry.resources[0].operations[0].representations[0].access = CompiledAccess::Protected { scope: "records:read".into(), purpose: None, row_binding: None, @@ -926,12 +1114,12 @@ mod tests { registry.metadata_visibility.processing = Visibility::OperationBound; let generated = generate_artifacts(®istry).expect("artifacts generate"); for id in [ - "record--read-vocabulary", - "record--read-context", - "record--read-schema", - "record--read-shacl", - "record--read-classifications", - "record--read-processing", + "record--read--representation-public-vocabulary", + "record--read--representation-public-context", + "record--read--representation-public-schema", + "record--read--representation-public-shacl", + "record--read--representation-public-classifications", + "record--read--representation-public-processing", ] { let artifact = generated .artifacts @@ -943,6 +1131,10 @@ mod tests { artifact.operation_identifier.as_deref(), Some("record.read") ); + assert_eq!( + artifact.representation_identifier.as_deref(), + Some("public") + ); } registry.metadata_visibility.semantics = Visibility::Public; @@ -953,12 +1145,17 @@ mod tests { generated .artifacts .iter() - .find(|artifact| artifact.id == "record--read-vocabulary") + .find(|artifact| { + artifact.id == "record--read--representation-public-vocabulary" + }) .expect("semantic projection") .visibility, - Visibility::Public + Visibility::OperationBound ); - for id in ["record--read-classifications", "record--read-processing"] { + for id in [ + "record--read--representation-public-classifications", + "record--read--representation-public-processing", + ] { assert_eq!( generated .artifacts diff --git a/crates/registry-relay-v2/src/audit.rs b/crates/registry-relay-v2/src/audit.rs index 6927c03ed..8d6cdfdbb 100644 --- a/crates/registry-relay-v2/src/audit.rs +++ b/crates/registry-relay-v2/src/audit.rs @@ -108,10 +108,13 @@ pub struct AuditContext { pub access_rule_revision: Option, pub purpose: Option, pub row_boundary_kind: RowBoundaryKind, + pub representation: Option, pub disclosure_profile: Option, pub processing_description_identifiers: Vec, pub selected_properties: Vec, - pub maximum_handling: Option, + pub processing_handling: Option, + pub disclosure_handling: Option, + pub transform_identifiers: Vec, pub contract_revision: String, pub source_revision: Option, pub principal_kind: PrincipalKind, @@ -177,11 +180,16 @@ struct AuditEvent { purpose: Option, row_boundary_kind: RowBoundaryKind, #[serde(skip_serializing_if = "Option::is_none")] + representation: Option, + #[serde(skip_serializing_if = "Option::is_none")] disclosure_profile: Option, processing_description_identifiers: Vec, selected_properties: Vec, #[serde(skip_serializing_if = "Option::is_none")] - maximum_handling: Option, + processing_handling: Option, + #[serde(skip_serializing_if = "Option::is_none")] + disclosure_handling: Option, + transform_identifiers: Vec, contract_revision: String, #[serde(skip_serializing_if = "Option::is_none")] source_revision: Option, @@ -207,10 +215,13 @@ impl AuditEvent { access_rule_revision: context.access_rule_revision.clone(), purpose: context.purpose.clone(), row_boundary_kind: context.row_boundary_kind, + representation: context.representation.clone(), disclosure_profile: context.disclosure_profile.clone(), processing_description_identifiers: context.processing_description_identifiers.clone(), selected_properties: context.selected_properties.clone(), - maximum_handling: context.maximum_handling.clone(), + processing_handling: context.processing_handling.clone(), + disclosure_handling: context.disclosure_handling.clone(), + transform_identifiers: context.transform_identifiers.clone(), contract_revision: context.contract_revision.clone(), source_revision: context.source_revision.as_ref().map(source_revision), principal_kind: context.principal_kind, diff --git a/crates/registry-relay-v2/src/compiler.rs b/crates/registry-relay-v2/src/compiler.rs index 7392edb1e..8a6b7f5d3 100644 --- a/crates/registry-relay-v2/src/compiler.rs +++ b/crates/registry-relay-v2/src/compiler.rs @@ -10,30 +10,35 @@ use sha2::{Digest, Sha256}; use url::Url; use crate::contract::{ - AccessRule, AuthorityRowBinding, ClassificationPartial, DataType, Handling, RegistryContract, - ReviewStatus, SourceProfile, + AccessRule, AuthorityRowBinding, ClassificationPartial, DataType, DateInputType, DatePrecision, + Handling, IdentificationMethod, RegistryContract, RepresentationDefinition, ReviewStatus, + SourceProfile, TransformDefinition, }; use crate::model::{ CapabilityFamily, ColumnAccount, ColumnUse, CompileProfile, CompileReport, CompiledAccess, - CompiledCodelist, CompiledDisclosureProfile, CompiledFilter, CompiledGovernedFile, - CompiledMetadataVisibility, CompiledOperation, CompiledPagination, CompiledProperty, - CompiledPurpose, CompiledRecordContext, CompiledRegistry, CompiledResource, CompiledRowBinding, - CompiledSelector, CompiledSource, ConsultationPattern, Diagnostic, DiagnosticSeverity, - EffectiveClassification, ObservedSourceSchema, OperationKind, QueryPlan, RowAuthoritySource, - StarterColumn, StarterContract, + CompiledClassificationReview, CompiledCodelist, CompiledDisclosureProfile, CompiledFilter, + CompiledGeneratedIdentificationBinding, CompiledGovernedFile, CompiledMetadataVisibility, + CompiledOperation, CompiledPagination, CompiledProperty, CompiledPurpose, + CompiledRecordContext, CompiledRegistry, CompiledRepresentation, CompiledResource, + CompiledRowBinding, CompiledSelector, CompiledSource, CompiledTransform, ConsultationPattern, + Diagnostic, DiagnosticSeverity, EffectiveClassification, ObservedSourceSchema, OperationKind, + QueryPlan, RowAuthoritySource, StarterColumn, StarterContract, }; const API_VERSION: &str = "relay.registrystack.org/v2alpha1"; -const RESERVED_PARAMETERS: [&str; 3] = ["pageSize", "cursor", "fields"]; +const RESERVED_PARAMETERS: [&str; 4] = ["pageSize", "cursor", "fields", "representation"]; const MAXIMUM_RESOURCES: usize = 128; const MAXIMUM_PROPERTIES_PER_RESOURCE: usize = 128; const MAXIMUM_DISCLOSURE_PROFILES_PER_RESOURCE: usize = 64; +const MAXIMUM_REPRESENTATIONS_PER_OPERATION: usize = 16; +const MAXIMUM_REPRESENTATION_EXECUTORS_PER_REGISTRY: usize = 128; const MAXIMUM_LIST_FILTERS: usize = 32; const MAXIMUM_LIST_ORDER_KEYS: usize = 32; const MAXIMUM_LIST_PAGE_SIZE: u32 = 1_000; const MAXIMUM_LOOKUP_REQUEST_BODY_BYTES: u32 = 1024 * 1024; const MAXIMUM_LOOKUP_SELECTORS: usize = 32; const MAXIMUM_SELECTOR_BYTES: u32 = 4 * 1024; +const MAXIMUM_PARTIAL_STRING_CHARACTERS: u16 = 64; pub type GovernedFileSet = BTreeMap>; @@ -61,6 +66,18 @@ pub fn compile_contract( let mut compiler = Compiler::new(contract, observed, profile); compiler.validate_top_level(); let resources = compiler.compile_resources(); + let representation_executors = resources + .iter() + .flat_map(|resource| &resource.operations) + .map(|operation| operation.representations.len()) + .sum::(); + if representation_executors > MAXIMUM_REPRESENTATION_EXECUTORS_PER_REGISTRY { + compiler.error( + "representation.registry_bound_exceeded", + "resources", + "the compiled representation count exceeds the Registry runtime ceiling", + ); + } compiler.validate_observed_source_closure(); if compiler.report.has_errors() { @@ -98,6 +115,7 @@ pub fn compile_contract( local_vocabulary: contract.semantics.local_vocabulary.clone(), semantic_alignments: contract.semantics.alignments.clone(), governed_files: Vec::new(), + classification_review: None, codelists: Vec::new(), sources: contract .sources @@ -130,7 +148,8 @@ pub fn compile_contract_with_governed_files( files: &GovernedFileSet, ) -> Result { let mut registry = compile_contract(contract, observed, profile)?; - let (codelists, file_digests, report) = validate_governed_files(contract, files, profile); + let (codelists, file_digests, classification_review, report) = + validate_governed_files(contract, files, profile, ®istry); if report.has_errors() { return Err(report); } @@ -153,10 +172,11 @@ pub fn compile_contract_with_governed_files( }], })?; registry.codelists = codelists; + registry.classification_review = classification_review; registry.governed_files = file_digests .into_iter() .map(|(path, sha256)| CompiledGovernedFile { - roles: governed_file_roles(contract, &path), + roles: governed_file_roles(contract, registry.classification_review.as_ref(), &path), path, sha256, }) @@ -164,7 +184,11 @@ pub fn compile_contract_with_governed_files( Ok(registry) } -fn governed_file_roles(contract: &RegistryContract, path: &str) -> Vec { +fn governed_file_roles( + contract: &RegistryContract, + review: Option<&CompiledClassificationReview>, + path: &str, +) -> Vec { let mut roles = Vec::new(); if contract.registry.identifier_lifecycle_policy_ref == path { roles.push("identifier-lifecycle-policy".into()); @@ -172,6 +196,15 @@ fn governed_file_roles(contract: &RegistryContract, path: &str) -> Vec { if contract.classifications.provenance_ref == path { roles.push("classification-provenance".into()); } + if review.is_some_and(|review| review.rationale_ref == path) { + roles.push("classification-review-rationale".into()); + } + if review + .and_then(|review| review.generated_identification.as_ref()) + .is_some_and(|binding| binding.report_ref == path) + { + roles.push("identification-report".into()); + } for alignment in &contract.semantics.alignments { if alignment.profile_ref == path { roles.push(format!("semantic-alignment:{}", alignment.id)); @@ -660,7 +693,7 @@ impl<'a> Compiler<'a> { } let mut property_names = HashSet::new(); - let mut property_columns: HashMap<&str, (&str, EffectiveClassification)> = + let mut property_columns: HashMap<&str, Vec<(&str, EffectiveClassification, bool)>> = HashMap::new(); let mut properties = Vec::with_capacity(resource.properties.len()); for (name, property) in resource.properties.iter() { @@ -693,13 +726,6 @@ impl<'a> Compiler<'a> { "property keys must be unique", ); } - if property_columns.contains_key(property.source_column.as_str()) { - self.error( - "property.column_reused", - &format!("{location}.sourceColumn"), - "one source column cannot back more than one public property", - ); - } if !column_exists(observed_columns.as_ref(), &property.source_column) { self.error( "property.column_unknown", @@ -722,12 +748,18 @@ impl<'a> Compiler<'a> { ); } } + let transform = self.compile_transform( + property.transform.as_ref(), + property.data_type, + &location, + ); if let Some(observed) = observed_view.and_then(|view| { view.columns .iter() .find(|column| column.name == property.source_column) }) { - if !compatible_declared_type(property.data_type, &observed.declared_type) { + let source_type = transform_source_type(transform.as_ref(), property.data_type); + if !compatible_declared_type(source_type, &observed.declared_type) { self.error( "property.declared_type_incompatible", &format!("{location}.type"), @@ -772,15 +804,16 @@ impl<'a> Compiler<'a> { property.semantic_term.clone() } }; - property_columns.insert( - property.source_column.as_str(), - (name, classification.clone()), - ); + property_columns + .entry(property.source_column.as_str()) + .or_default() + .push((name, classification.clone(), transform.is_some())); properties.push(CompiledProperty { name: name.to_owned(), label: property.label.clone(), description: property.description.clone(), source_column: property.source_column.clone(), + transform, data_type: property.data_type, codelist: property.codelist.clone(), source_required: property.source_required, @@ -918,6 +951,7 @@ impl<'a> Compiler<'a> { resource, &properties, &disclosures, + observed_view, observed_columns.as_ref(), &root, list, @@ -935,8 +969,8 @@ impl<'a> Compiler<'a> { &root, "read", OperationKind::Read, - &read.access, - &read.disclosure_profile, + &read.default_representation, + &read.representations, ) { operations.push(operation); } @@ -1048,8 +1082,8 @@ impl<'a> Compiler<'a> { OperationKind::Lookup { name: lookup.id.clone(), }, - &lookup.access, - &lookup.disclosure_profile, + &lookup.default_representation, + &lookup.representations, ) { operation.identifier = format!("{}.lookup.{}", resource.id, lookup.id); operation.query.selectors = selectors; @@ -1150,26 +1184,40 @@ impl<'a> Compiler<'a> { root: &str, operation_location: &str, kind: OperationKind, - access: &AccessRule, - disclosure_name: &str, + default_representation: &str, + representation_definitions: &crate::contract::OrderedMap, ) -> Option { let location = if operation_location == "lookup" { root.to_owned() } else { format!("{root}.operations.{operation_location}") }; - let disclosure = disclosures.iter().find(|item| item.id == disclosure_name); - let Some(disclosure) = disclosure else { + if representation_definitions.is_empty() { self.error( - "operation.disclosure_unknown", - &format!("{location}.disclosureProfile"), - "the operation names no disclosure profile", + "representation.none", + &format!("{location}.representations"), + "an operation must declare at least one finite representation", ); return None; - }; - let access = self.compile_access(access, observed_columns, &location)?; - validate_disclosure_access(&mut self.report, disclosure, &access, &location); - let projected_columns = projected_columns(resource, properties, &disclosure.properties); + } + if representation_definitions.len() > MAXIMUM_REPRESENTATIONS_PER_OPERATION { + self.error( + "representation.bound_exceeded", + &format!("{location}.representations"), + "the representation count exceeds the per-operation product ceiling", + ); + } + if !valid_kebab_identifier(default_representation) + || representation_definitions + .get(default_representation) + .is_none() + { + self.error( + "representation.default_invalid", + &format!("{location}.defaultRepresentation"), + "the explicit default must name exactly one declared representation", + ); + } let identifier = match &kind { OperationKind::Read => format!("{}.read", resource.id), OperationKind::List => format!("{}.list", resource.id), @@ -1181,18 +1229,105 @@ impl<'a> Compiler<'a> { OperationKind::Lookup { .. } => ConsultationPattern::Search, }; let artifact_stem = operation_artifact_stem(&resource.id, &kind); + let mut representations = Vec::with_capacity(representation_definitions.len()); + for (representation_id, definition) in representation_definitions.iter() { + let representation_location = format!("{location}.representations.{representation_id}"); + if !valid_kebab_identifier(representation_id) { + self.error( + "representation.id_invalid", + &representation_location, + "representation identifiers must be URL-safe kebab case", + ); + } + let Some(disclosure) = disclosures + .iter() + .find(|item| item.id == definition.disclosure_profile) + else { + self.error( + "representation.disclosure_unknown", + &format!("{representation_location}.disclosureProfile"), + "the representation names no disclosure profile", + ); + continue; + }; + let Some(access) = self.compile_access( + &definition.access, + observed_columns, + &representation_location, + ) else { + continue; + }; + validate_disclosure_access( + &mut self.report, + disclosure, + &access, + matches!(&kind, OperationKind::List), + &representation_location, + ); + let representation_artifact_stem = + format!("{artifact_stem}--representation-{representation_id}"); + representations.push(CompiledRepresentation { + id: representation_id.to_owned(), + access, + disclosure_profile: disclosure.id.clone(), + selectable_properties: disclosure.properties.clone(), + projected_columns: projected_columns(resource, properties, &disclosure.properties), + processing_handling: Handling::Public, + disclosure_handling: disclosure.maximum_handling, + transform_inventory: disclosure + .properties + .iter() + .filter_map(|name| { + properties + .iter() + .find(|property| property.name == *name) + .and_then(|property| { + property.transform.as_ref().map(|transform| { + format!("{}={}", property.name, transform.identifier()) + }) + }) + }) + .collect(), + schema_reference: artifact_url( + &self.contract.registry.base_uri, + &format!("{representation_artifact_stem}-schema"), + ), + semantic_model_reference: artifact_url( + &self.contract.registry.base_uri, + &format!("{representation_artifact_stem}-vocabulary"), + ), + context_reference: artifact_url( + &self.contract.registry.base_uri, + &format!("{representation_artifact_stem}-context"), + ), + }); + } + if representations + .iter() + .any(|representation| matches!(representation.access, CompiledAccess::Public)) + && representations + .iter() + .find(|representation| representation.id == default_representation) + .is_some_and(|representation| { + !matches!(representation.access, CompiledAccess::Public) + }) + { + self.error( + "representation.public_default_required", + &format!("{location}.defaultRepresentation"), + "an operation with a public representation must use a public default", + ); + } Some(CompiledOperation { identifier, family: CapabilityFamily::Consultation, pattern, kind, - access, - disclosure_profile: disclosure.id.clone(), - selectable_properties: disclosure.properties.clone(), + default_representation: default_representation.to_owned(), + representations, query: QueryPlan { source: resource.source.source.clone(), view: resource.source.view.clone(), - projected_columns, filters: Vec::new(), selectors: Vec::new(), order_by: Vec::new(), @@ -1200,19 +1335,6 @@ impl<'a> Compiler<'a> { pagination: None, maximum_request_body_bytes: None, }, - maximum_handling: disclosure.maximum_handling, - schema_reference: artifact_url( - &self.contract.registry.base_uri, - &format!("{artifact_stem}-schema"), - ), - semantic_model_reference: artifact_url( - &self.contract.registry.base_uri, - &format!("{artifact_stem}-vocabulary"), - ), - context_reference: artifact_url( - &self.contract.registry.base_uri, - &format!("{artifact_stem}-context"), - ), }) } @@ -1222,6 +1344,7 @@ impl<'a> Compiler<'a> { resource: &crate::contract::ResourceDefinition, properties: &[CompiledProperty], disclosures: &[CompiledDisclosureProfile], + observed_view: Option<&crate::model::ObservedView>, observed_columns: Option<&BTreeSet<&str>>, root: &str, list: &crate::contract::ListOperation, @@ -1234,8 +1357,8 @@ impl<'a> Compiler<'a> { root, "list", OperationKind::List, - &list.access, - &list.disclosure_profile, + &list.default_representation, + &list.representations, )?; let location = format!("{root}.operations.list"); if list.filters.len() > MAXIMUM_LIST_FILTERS { @@ -1323,6 +1446,7 @@ impl<'a> Compiler<'a> { } } let mut order = HashSet::new(); + let mut order_columns = HashSet::new(); for property_name in &list.order_by { if !order.insert(property_name.as_str()) { self.error( @@ -1335,10 +1459,32 @@ impl<'a> Compiler<'a> { .iter() .find(|property| property.name == *property_name) { - Some(property) => operation - .query - .order_by - .push(property.source_column.clone()), + Some(property) => { + if !property.source_required { + self.error( + "list.order_property_optional", + &format!("{location}.orderBy"), + "fixed order properties must be required in the governed source contract", + ); + } + if !order_columns.insert(property.source_column.as_str()) { + self.error( + "list.order_column_duplicate", + &format!("{location}.orderBy"), + "fixed order properties must resolve to distinct source columns", + ); + } + self.validate_cursor_order_column( + observed_view, + &property.source_column, + property.data_type, + &format!("{location}.orderBy"), + ); + operation + .query + .order_by + .push(property.source_column.clone()); + } None => self.error( "list.order_property_unknown", &format!("{location}.orderBy"), @@ -1347,8 +1493,27 @@ impl<'a> Compiler<'a> { } } let record_identifier = &resource.record_context.record_identifier.source_column; - if !operation.query.order_by.contains(record_identifier) { - operation.query.order_by.push(record_identifier.clone()); + // The globally unique Registry Record identifier is always the final + // keyset component. Moving an explicitly authored occurrence to the + // end makes the order deterministic and guarantees a unique final + // tie-breaker rather than merely checking that it occurs somewhere. + operation + .query + .order_by + .retain(|column| column != record_identifier); + operation.query.order_by.push(record_identifier.clone()); + self.validate_cursor_order_column( + observed_view, + record_identifier, + DataType::String, + &format!("{location}.orderBy"), + ); + if operation.query.order_by.len() > MAXIMUM_LIST_ORDER_KEYS { + self.error( + "list.order_bound_exceeded", + &format!("{location}.orderBy"), + "fixed order keys plus the required record-identifier tie-breaker exceed the product ceiling", + ); } operation.query.allow_unfiltered = list.allow_unfiltered; operation.query.pagination = Some(CompiledPagination { @@ -1358,6 +1523,98 @@ impl<'a> Compiler<'a> { Some(operation) } + fn validate_cursor_order_column( + &mut self, + observed_view: Option<&crate::model::ObservedView>, + source_column: &str, + data_type: DataType, + location: &str, + ) { + let Some(observed) = observed_view.and_then(|view| { + view.columns + .iter() + .find(|column| column.name == source_column) + }) else { + return; + }; + // SQLite does not preserve NOT NULL metadata through views: even a + // direct projection of a NOT NULL base-table column is reported as + // nullable by PRAGMA table_xinfo(view). The authored sourceRequired + // contract and runtime value validation therefore own null rejection; + // observed metadata still closes the declared scalar type here. + if !compatible_declared_type(data_type, &observed.declared_type) { + self.error( + "list.order_column_type_unsupported", + location, + "keyset order columns must have a reviewed SQLite declaration supported by the cursor scalar profile", + ); + } + } + + fn compile_transform( + &mut self, + definition: Option<&TransformDefinition>, + output_type: DataType, + location: &str, + ) -> Option { + match definition? { + TransformDefinition::PartialString { reveal, characters } => { + if output_type != DataType::String { + self.error( + "transform.output_type_invalid", + &format!("{location}.type"), + "partial-string transforms must publish a string property", + ); + } + if !(1..=MAXIMUM_PARTIAL_STRING_CHARACTERS).contains(characters) { + self.error( + "transform.partial_string_characters_invalid", + &format!("{location}.transform.characters"), + "partial-string reveal length must be within the fixed product bound", + ); + } + let reveal_label = match reveal { + crate::contract::PartialStringReveal::Prefix => "prefix", + crate::contract::PartialStringReveal::Suffix => "suffix", + }; + Some(CompiledTransform::PartialString { + identifier: format!("partial-string:{reveal_label}:{characters}"), + reveal: *reveal, + characters: *characters, + }) + } + TransformDefinition::DatePrecision { + source_type, + precision, + } => { + let expected_output = match precision { + DatePrecision::Year => DataType::Year, + DatePrecision::YearMonth => DataType::YearMonth, + }; + if output_type != expected_output { + self.error( + "transform.output_type_invalid", + &format!("{location}.type"), + "date-precision output datatype must match the selected precision", + ); + } + let source_label = match source_type { + DateInputType::Date => "date", + DateInputType::DateTime => "date-time", + }; + let precision_label = match precision { + DatePrecision::Year => "year", + DatePrecision::YearMonth => "year-month", + }; + Some(CompiledTransform::DatePrecision { + identifier: format!("date-precision:{source_label}:{precision_label}"), + source_type: *source_type, + precision: *precision, + }) + } + } + } + fn compile_access( &mut self, access: &AccessRule, @@ -1456,7 +1713,7 @@ impl<'a> Compiler<'a> { resource: &crate::contract::ResourceDefinition, properties: &[CompiledProperty], operations: &[CompiledOperation], - property_columns: &HashMap<&str, (&str, EffectiveClassification)>, + property_columns: &HashMap<&str, Vec<(&str, EffectiveClassification, bool)>>, core: &[(&str, ColumnUse); 4], observed_columns: Option<&BTreeSet<&str>>, root: &str, @@ -1484,14 +1741,19 @@ impl<'a> Compiler<'a> { .or_default() .insert(ColumnUse::Selector(selector.name.clone())); } - if let CompiledAccess::Protected { - row_binding: Some(row_binding), - .. - } = &operation.access - { - uses.entry(&row_binding.source_column) - .or_default() - .insert(ColumnUse::RowBinding(operation.identifier.clone())); + for representation in &operation.representations { + if let CompiledAccess::Protected { + row_binding: Some(row_binding), + .. + } = &representation.access + { + uses.entry(&row_binding.source_column).or_default().insert( + ColumnUse::RowBinding(format!( + "{}:{}", + operation.identifier, representation.id + )), + ); + } } } if let Some(columns) = observed_columns { @@ -1518,9 +1780,32 @@ impl<'a> Compiler<'a> { let mut accounts = Vec::with_capacity(uses.len()); for (column, column_uses) in uses { let source_override = resource.source_column_classifications.get(column); - let property_classification = property_columns.get(column).map(|(_, item)| item); + let property_bindings = property_columns.get(column); + let requires_explicit_review = property_bindings.is_some_and(|bindings| { + bindings.len() > 1 || bindings.iter().any(|(_, _, transformed)| *transformed) + }); + if requires_explicit_review + && !source_override.is_some_and(explicit_reviewed_classification) + { + if self.profile == CompileProfile::Production { + self.error( + "classification.column_explicit_review_required", + &format!("{root}.sourceColumnClassifications.{column}"), + "a transformed or multiply-bound source column requires its own complete reviewed classification", + ); + } else { + self.warning( + "classification.column_explicit_review_required", + &format!("{root}.sourceColumnClassifications.{column}"), + "a transformed or multiply-bound source column still requires its own complete reviewed classification", + ); + } + } + let property_classification = property_bindings + .and_then(|bindings| bindings.first()) + .map(|(_, item, _)| item); let classification = match property_classification { - Some(property) => effective_classification( + Some(property) if !requires_explicit_review => effective_classification( self.contract, &classification_to_partial(property), source_override, @@ -1530,6 +1815,11 @@ impl<'a> Compiler<'a> { &resource.classification_defaults, source_override, ), + Some(_) => effective_classification( + self.contract, + &resource.classification_defaults, + source_override, + ), }; let Some(classification) = classification else { self.error( @@ -1539,12 +1829,19 @@ impl<'a> Compiler<'a> { ); continue; }; - if let Some(property) = property_classification { - if classification.handling < property.handling { + if let Some(bindings) = property_bindings { + let strongest_direct = bindings + .iter() + .filter(|(_, _, transformed)| !*transformed) + .map(|(_, item, _)| item.handling) + .max(); + if source_override.is_some_and(explicit_reviewed_classification) + && strongest_direct.is_some_and(|handling| classification.handling < handling) + { self.error( "classification.column_weaker_than_property", &format!("{root}.sourceColumnClassifications"), - "a source-column override cannot weaken property handling", + "a source-column classification cannot weaken a direct property handling floor", ); } } @@ -1568,36 +1865,6 @@ impl<'a> Compiler<'a> { root: &str, ) { for operation in operations { - let mut referenced = BTreeSet::new(); - referenced.extend(operation.query.projected_columns.iter().map(String::as_str)); - referenced.extend( - operation - .query - .filters - .iter() - .map(|filter| filter.source_column.as_str()), - ); - referenced.extend(operation.query.order_by.iter().map(String::as_str)); - referenced.extend( - operation - .query - .selectors - .iter() - .map(|selector| selector.source_column.as_str()), - ); - if let CompiledAccess::Protected { - row_binding: Some(binding), - .. - } = &operation.access - { - referenced.insert(&binding.source_column); - } - operation.maximum_handling = columns - .iter() - .filter(|column| referenced.contains(column.column.as_str())) - .fold(operation.maximum_handling, |maximum, column| { - maximum.max(column.classification.handling) - }); let location = match &operation.kind { OperationKind::List => format!("{root}.operations.list"), OperationKind::Read => format!("{root}.operations.read"), @@ -1605,23 +1872,57 @@ impl<'a> Compiler<'a> { format!("{root}.operations.lookups.{name}") } }; - if operation.maximum_handling > Handling::Public - && matches!(operation.access, CompiledAccess::Public) - { - self.error( - "access.public_nonpublic_forbidden", - &location, - "anonymous operations may process only public-handling reviewed columns", + for representation in &mut operation.representations { + let mut referenced = BTreeSet::new(); + referenced.extend(representation.projected_columns.iter().map(String::as_str)); + referenced.extend( + operation + .query + .filters + .iter() + .map(|filter| filter.source_column.as_str()), ); - } - if operation.maximum_handling == Handling::Restricted - && matches!(&operation.kind, OperationKind::List) - { - self.error( - "operation.restricted_list_forbidden", - &location, - "restricted reviewed data cannot be processed by a collection list", + referenced.extend(operation.query.order_by.iter().map(String::as_str)); + referenced.extend( + operation + .query + .selectors + .iter() + .map(|selector| selector.source_column.as_str()), ); + if let CompiledAccess::Protected { + row_binding: Some(binding), + .. + } = &representation.access + { + referenced.insert(&binding.source_column); + } + representation.processing_handling = columns + .iter() + .filter(|column| referenced.contains(column.column.as_str())) + .fold(Handling::Public, |maximum, column| { + maximum.max(column.classification.handling) + }); + let representation_location = + format!("{location}.representations.{}", representation.id); + if representation.processing_handling > Handling::Public + && matches!(representation.access, CompiledAccess::Public) + { + self.error( + "access.public_nonpublic_forbidden", + &representation_location, + "anonymous representations may process only public-handling reviewed columns", + ); + } + if representation.processing_handling == Handling::Restricted + && matches!(&operation.kind, OperationKind::List) + { + self.error( + "operation.restricted_list_forbidden", + &representation_location, + "restricted reviewed data cannot be processed by a collection list", + ); + } } } } @@ -1630,14 +1931,17 @@ impl<'a> Compiler<'a> { &mut self, _resource: &crate::contract::ResourceDefinition, operations: &[CompiledOperation], - properties: &[CompiledProperty], - root: &str, + _properties: &[CompiledProperty], + _root: &str, ) { use crate::contract::Visibility; - let has_public = operations - .iter() - .any(|operation| matches!(operation.access, CompiledAccess::Public)); + let has_public = operations.iter().any(|operation| { + operation + .representations + .iter() + .any(|representation| matches!(representation.access, CompiledAccess::Public)) + }); for (name, visibility) in [ ("resources", self.contract.metadata_visibility.resources), ("semantics", self.contract.metadata_visibility.semantics), @@ -1652,23 +1956,9 @@ impl<'a> Compiler<'a> { ); } } - let confidential = properties - .iter() - .any(|property| property.classification.handling >= Handling::Confidential); - if confidential && self.contract.metadata_visibility.classifications == Visibility::Public { - self.error( - "metadata.classification_visibility_invalid", - &format!("{root}.properties"), - "confidential or restricted properties forbid public classification metadata", - ); - } - if confidential && self.contract.metadata_visibility.processing == Visibility::Public { - self.error( - "metadata.processing_visibility_invalid", - &format!("{root}.processingDescriptions"), - "confidential or restricted properties forbid public processing metadata", - ); - } + // Classification and processing artifacts are projected per finite + // representation. A protected representation is operation-bound even + // when a public sibling permits public metadata for its own profile. } fn validate_processing( @@ -1779,6 +2069,194 @@ fn revision(value: &T) -> Result { Ok(format!("sha256:{}", hex::encode(Sha256::digest(canonical)))) } +/// Digest the non-circular inventory an institutional classification review +/// accepts. Contract revisions, governed file bytes, and review metadata are +/// deliberately absent; processed source columns, governed properties, query +/// uses, and finite representation disclosures are present. +pub fn classification_inventory_digest( + registry: &CompiledRegistry, +) -> Result { + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct Inventory<'a> { + registry_identifier: &'a str, + sources: Vec>, + resources: Vec>, + } + + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct SourceInventory<'a> { + id: &'a str, + profile: SourceProfile, + expected_schema_fingerprint: &'a str, + } + + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct ResourceInventory<'a> { + id: &'a str, + source: &'a str, + view: &'a str, + record_context: RecordContextInventory<'a>, + properties: Vec>, + column_accounting: Vec>, + disclosure_profiles: Vec>, + operations: Vec>, + } + + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct RecordContextInventory<'a> { + record_identifier_column: &'a str, + revision_identifier_column: &'a str, + lifecycle_state_column: &'a str, + lifecycle_state_codelist: &'a str, + recorded_at_column: &'a str, + } + + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct PropertyInventory<'a> { + name: &'a str, + source_column: &'a str, + transform: &'a Option, + data_type: DataType, + codelist: &'a Option, + source_required: bool, + semantic_iri: &'a str, + classification: &'a EffectiveClassification, + } + + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct ColumnInventory<'a> { + column: &'a str, + uses: &'a [ColumnUse], + classification: &'a EffectiveClassification, + } + + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct DisclosureInventory<'a> { + id: &'a str, + properties: &'a [String], + maximum_handling: Handling, + } + + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct OperationInventory<'a> { + kind: &'a OperationKind, + default_representation: &'a str, + representations: Vec>, + } + + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct RepresentationInventory<'a> { + id: &'a str, + access: &'a CompiledAccess, + disclosure_profile: &'a str, + selectable_properties: &'a [String], + projected_columns: &'a [String], + processing_handling: Handling, + disclosure_handling: Handling, + transform_inventory: &'a [String], + } + + let inventory = Inventory { + registry_identifier: ®istry.registry_identifier, + sources: registry + .sources + .iter() + .map(|source| SourceInventory { + id: &source.id, + profile: source.profile, + expected_schema_fingerprint: &source.expected_schema_fingerprint, + }) + .collect(), + resources: registry + .resources + .iter() + .map(|resource| ResourceInventory { + id: &resource.id, + source: &resource.source, + view: &resource.view, + record_context: RecordContextInventory { + record_identifier_column: &resource.record_context.record_identifier_column, + revision_identifier_column: &resource.record_context.revision_identifier_column, + lifecycle_state_column: &resource.record_context.lifecycle_state_column, + lifecycle_state_codelist: &resource.record_context.lifecycle_state_codelist, + recorded_at_column: &resource.record_context.recorded_at_column, + }, + properties: resource + .properties + .iter() + .map(|property| PropertyInventory { + name: &property.name, + source_column: &property.source_column, + transform: &property.transform, + data_type: property.data_type, + codelist: &property.codelist, + source_required: property.source_required, + semantic_iri: &property.semantic_iri, + classification: &property.classification, + }) + .collect(), + column_accounting: resource + .column_accounting + .iter() + .map(|column| ColumnInventory { + column: &column.column, + uses: &column.uses, + classification: &column.classification, + }) + .collect(), + disclosure_profiles: resource + .disclosure_profiles + .iter() + .map(|disclosure| DisclosureInventory { + id: &disclosure.id, + properties: &disclosure.properties, + maximum_handling: disclosure.maximum_handling, + }) + .collect(), + operations: resource + .operations + .iter() + .map(|operation| OperationInventory { + kind: &operation.kind, + default_representation: &operation.default_representation, + representations: operation + .representations + .iter() + .map(|representation| RepresentationInventory { + id: &representation.id, + access: &representation.access, + disclosure_profile: &representation.disclosure_profile, + selectable_properties: &representation.selectable_properties, + projected_columns: &representation.projected_columns, + processing_handling: representation.processing_handling, + disclosure_handling: representation.disclosure_handling, + transform_inventory: &representation.transform_inventory, + }) + .collect(), + }) + .collect(), + }) + .collect(), + }; + revision(&inventory).map_err(|()| CompileReport { + diagnostics: vec![Diagnostic { + severity: DiagnosticSeverity::Error, + code: "classification.inventory_canonicalization_failed".into(), + location: "classifications.provenanceRef".into(), + message: "the classification inventory could not be canonicalized".into(), + }], + }) +} + #[derive(Deserialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] struct CodelistDocument { @@ -1809,13 +2287,149 @@ struct SemanticMapping { relation: String, } +fn parse_classification_review( + contract: &RegistryContract, + files: &GovernedFileSet, + profile: CompileProfile, + registry: &CompiledRegistry, + report: &mut CompileReport, +) -> Option { + let path = &contract.classifications.provenance_ref; + let content = files.get(path)?; + let document = match crate::identification::parse_classification_review_yaml(content) { + Ok(document) => document, + Err(_) => { + review_diagnostic( + report, + profile, + "classification.review_invalid", + path, + "the classification review is not valid strict governed YAML", + ); + return None; + } + }; + let inventory_digest = match classification_inventory_digest(registry) { + Ok(digest) => digest, + Err(failure) => { + report.diagnostics.extend(failure.diagnostics); + return None; + } + }; + let mut expected_bytes = None; + let expected_generated = if document.method == IdentificationMethod::Generated { + crate::identification::identify_contract(contract, &observed_schemas(registry)) + .ok() + .and_then(|expected_report| { + let bytes = + crate::identification::render_identification_report(&expected_report).ok()?; + let report_digest = + crate::identification::identification_report_digest(&expected_report).ok()?; + let rule_pack = crate::identification::core_pack_reference().ok()?; + expected_bytes = Some(bytes); + Some(crate::contract::GeneratedIdentificationBinding { + report_ref: crate::identification::REVIEWED_IDENTIFICATION_REPORT_PATH.into(), + report_digest, + rule_pack, + }) + }) + } else { + None + }; + let expectation = crate::identification::ClassificationReviewExpectation { + registry_identifier: contract.registry.registry_identifier.clone(), + classification_inventory_digest: inventory_digest, + generated_identification: expected_generated, + }; + let validation = crate::identification::validate_classification_review(&document, &expectation); + let mut accepted = validation.is_valid(); + for diagnostic in validation.diagnostics { + review_diagnostic( + report, + profile, + &diagnostic.code, + &format!("{path}:{}", diagnostic.location), + &diagnostic.message, + ); + } + if document.status == ReviewStatus::Reviewed + && document.method == IdentificationMethod::Generated + { + let actual = document + .generated_identification + .as_ref() + .and_then(|binding| files.get(&binding.report_ref)); + if actual + .zip(expected_bytes.as_ref()) + .is_none_or(|(actual, expected)| actual != expected) + { + accepted = false; + review_diagnostic( + report, + profile, + "classification.review_identification_report_mismatch", + path, + "the governed identification report bytes do not match independent recomputation", + ); + } + } + let generated_identification = document.generated_identification.as_ref().map(|binding| { + CompiledGeneratedIdentificationBinding { + report_ref: binding.report_ref.clone(), + report_digest: binding.report_digest.clone(), + rule_pack_id: binding.rule_pack.id.clone(), + rule_pack_version: binding.rule_pack.version.clone(), + rule_pack_digest: binding.rule_pack.digest.clone(), + } + }); + accepted.then_some(CompiledClassificationReview { + registry_identifier: document.registry_identifier, + classification_inventory_digest: document.classification_inventory_digest, + method: document.method, + reviewer: document.reviewer, + review_date: document.review_date, + status: document.status, + rationale_ref: document.rationale_ref, + generated_identification, + }) +} + +fn review_diagnostic( + report: &mut CompileReport, + profile: CompileProfile, + code: &str, + location: &str, + message: &str, +) { + report.diagnostics.push(Diagnostic { + severity: if profile == CompileProfile::Production { + DiagnosticSeverity::Error + } else { + DiagnosticSeverity::Warning + }, + code: code.into(), + location: location.into(), + message: message.into(), + }); +} + +fn observed_schemas(registry: &CompiledRegistry) -> Vec { + registry + .sources + .iter() + .filter_map(|source| source.observed_schema.clone()) + .collect() +} + fn validate_governed_files( contract: &RegistryContract, files: &GovernedFileSet, profile: CompileProfile, + registry: &CompiledRegistry, ) -> ( Vec, BTreeMap, + Option, CompileReport, ) { let mut report = CompileReport { @@ -1831,11 +2445,10 @@ fn validate_governed_files( location: "governed".into(), message: "the governed file closure exceeds its file or byte bound".into(), }); - return (Vec::new(), BTreeMap::new(), report); + return (Vec::new(), BTreeMap::new(), None, report); } let mut codelist_paths = BTreeSet::new(); let mut sidecar_paths = BTreeSet::new(); - codelist_paths.insert(contract.registry.identifier_lifecycle_policy_ref.as_str()); sidecar_paths.insert(contract.registry.identifier_lifecycle_policy_ref.as_str()); sidecar_paths.insert(contract.classifications.provenance_ref.as_str()); for alignment in &contract.semantics.alignments { @@ -1853,8 +2466,24 @@ fn validate_governed_files( sidecar_paths.insert(processing.dpv_profile_ref.as_str()); } } - // The lifecycle policy is a governance sidecar, not a codelist. - codelist_paths.remove(contract.registry.identifier_lifecycle_policy_ref.as_str()); + if codelist_paths.contains(contract.registry.identifier_lifecycle_policy_ref.as_str()) { + report.diagnostics.push(Diagnostic { + severity: DiagnosticSeverity::Error, + code: "contract.governed_file_role_collision".into(), + location: contract.registry.identifier_lifecycle_policy_ref.clone(), + message: + "one governed file cannot be both the identifier-lifecycle policy and a codelist" + .into(), + }); + } + let classification_review = + parse_classification_review(contract, files, profile, registry, &mut report); + if let Some(review) = &classification_review { + sidecar_paths.insert(review.rationale_ref.as_str()); + if let Some(generated) = &review.generated_identification { + sidecar_paths.insert(generated.report_ref.as_str()); + } + } let expected = sidecar_paths .iter() .chain(codelist_paths.iter()) @@ -1863,7 +2492,11 @@ fn validate_governed_files( for path in files.keys() { if !expected.contains(path.as_str()) { report.diagnostics.push(Diagnostic { - severity: DiagnosticSeverity::Error, + severity: if profile == CompileProfile::Authoring { + DiagnosticSeverity::Warning + } else { + DiagnosticSeverity::Error + }, code: "contract.governed_file_unknown".into(), location: path.clone(), message: "the governed closure contains an unreferenced file".into(), @@ -1874,7 +2507,13 @@ fn validate_governed_files( for path in &expected { let Some(content) = files.get(*path) else { report.diagnostics.push(Diagnostic { - severity: DiagnosticSeverity::Error, + severity: if profile == CompileProfile::Authoring + && *path == contract.classifications.provenance_ref + { + DiagnosticSeverity::Warning + } else { + DiagnosticSeverity::Error + }, code: "contract.governed_file_missing".into(), location: (*path).into(), message: "a referenced governed file is absent from the captured closure".into(), @@ -1882,7 +2521,8 @@ fn validate_governed_files( continue; }; file_digests.insert((*path).into(), digest(content)); - if !codelist_paths.contains(path) + if *path != contract.classifications.provenance_ref + && !codelist_paths.contains(path) && !contract .semantics .alignments @@ -2004,7 +2644,7 @@ fn validate_governed_files( } } codelists.sort_by(|left, right| left.path.cmp(&right.path)); - (codelists, file_digests, report) + (codelists, file_digests, classification_review, report) } fn digest(content: &[u8]) -> String { @@ -2051,10 +2691,24 @@ fn classification_to_partial(value: &EffectiveClassification) -> ClassificationP } } +fn explicit_reviewed_classification(value: &ClassificationPartial) -> bool { + value + .privacy + .as_deref() + .is_some_and(|item| !item.trim().is_empty()) + && value + .institutional + .as_deref() + .is_some_and(|item| !item.trim().is_empty()) + && value.handling.is_some() + && value.status == Some(ReviewStatus::Reviewed) +} + fn validate_disclosure_access( report: &mut CompileReport, disclosure: &CompiledDisclosureProfile, access: &CompiledAccess, + is_list: bool, location: &str, ) { if disclosure.maximum_handling > Handling::Public && matches!(access, CompiledAccess::Public) { @@ -2065,8 +2719,7 @@ fn validate_disclosure_access( message: "public operations may disclose only public handling data".into(), }); } - if disclosure.maximum_handling == Handling::Restricted && location.ends_with("operations.list") - { + if disclosure.maximum_handling == Handling::Restricted && is_list { report.diagnostics.push(Diagnostic { severity: DiagnosticSeverity::Error, code: "disclosure.restricted_list_forbidden".into(), @@ -2090,13 +2743,9 @@ fn projected_columns( ] { push_unique(&mut columns, column); } - // Full reviewed property projection is intentional: authoritative source - // validation precedes narrowing and serialization. - for property in properties { - push_unique(&mut columns, &property.source_column); - } - // Preserve the disclosure reference in the calculation so a future - // derived property cannot accidentally be omitted from the full plan. + // Only the selected finite representation may widen the Registry Core + // projection. This is what lets a public representation prove that it + // never processes a hidden non-public source column. for name in disclosure { if let Some(property) = properties.iter().find(|property| property.name == *name) { push_unique(&mut columns, &property.source_column); @@ -2184,7 +2833,12 @@ fn compatible_declared_type(data_type: DataType, declared_type: &str) -> bool { let declared = declared_type.trim().to_ascii_uppercase(); match data_type { DataType::Boolean | DataType::Integer => declared.contains("INT") || declared == "BOOLEAN", - DataType::String | DataType::Date | DataType::DateTime | DataType::ControlledCode => { + DataType::String + | DataType::Date + | DataType::DateTime + | DataType::Year + | DataType::YearMonth + | DataType::ControlledCode => { declared.contains("CHAR") || declared.contains("CLOB") || declared.contains("TEXT") @@ -2194,6 +2848,21 @@ fn compatible_declared_type(data_type: DataType, declared_type: &str) -> bool { } } +fn transform_source_type(transform: Option<&CompiledTransform>, output_type: DataType) -> DataType { + match transform { + Some(CompiledTransform::PartialString { .. }) => DataType::String, + Some(CompiledTransform::DatePrecision { + source_type: DateInputType::Date, + .. + }) => DataType::Date, + Some(CompiledTransform::DatePrecision { + source_type: DateInputType::DateTime, + .. + }) => DataType::DateTime, + None => output_type, + } +} + fn validate_observed_schema( report: &mut CompileReport, schema: &ObservedSourceSchema, @@ -2382,14 +3051,348 @@ pub(crate) mod tests { let second_artifacts = crate::artifacts::generate_artifacts(&second).expect("artifacts"); assert_eq!(first_artifacts, second_artifacts); let operation = &first.resources[0].operations[0]; + let representation = &operation.representations[0]; let schema = first_artifacts .artifacts .iter() - .find(|artifact| operation.schema_reference.ends_with(&artifact.id)) + .find(|artifact| representation.schema_reference.ends_with(&artifact.id)) .expect("operation schema is mounted by its exact artifact identifier"); assert_eq!(schema.visibility, crate::contract::Visibility::Public); } + #[test] + fn identifier_lifecycle_policy_cannot_alias_a_referenced_codelist() { + let yaml = valid_contract().replace( + "lifecycleState: {sourceColumn: lifecycle, codelist: codelists/record-lifecycle.yaml}", + "lifecycleState: {sourceColumn: lifecycle, codelist: governance/identifier-lifecycle.yaml}", + ); + let contract = RegistryContract::parse_yaml(&yaml).expect("strict contract"); + let report = compile_contract_with_governed_files( + &contract, + &[observed_schema()], + CompileProfile::Production, + &governed_files_for(&contract), + ) + .expect_err("one file cannot carry incompatible governed roles"); + assert!(report.diagnostics.iter().any(|diagnostic| { + diagnostic.code == "contract.governed_file_role_collision" + && diagnostic.location == "governance/identifier-lifecycle.yaml" + })); + } + + #[test] + fn every_referenced_property_codelist_must_be_in_the_governed_closure() { + let yaml = valid_contract().replace( + "type: string\n sourceRequired: true", + "type: controlled-code\n codelist: codelists/names.yaml\n sourceRequired: true", + ); + let contract = RegistryContract::parse_yaml(&yaml).expect("strict contract"); + let report = compile_contract_with_governed_files( + &contract, + &[observed_schema()], + CompileProfile::Production, + &governed_files_for(&contract), + ) + .expect_err("a referenced codelist cannot be absent"); + assert!(report.diagnostics.iter().any(|diagnostic| { + diagnostic.code == "contract.governed_file_missing" + && diagnostic.location == "codelists/names.yaml" + })); + } + + #[test] + fn list_order_ends_in_one_non_null_string_record_identifier() { + let yaml = valid_contract() + .replace( + " semanticTerm: local:name\n disclosureProfiles", + " semanticTerm: local:name\n recordId:\n label: Record identifier\n description: Stable record identifier\n sourceColumn: id\n type: string\n sourceRequired: true\n semanticTerm: local:recordId\n disclosureProfiles", + ) + .replace( + "read:\n defaultRepresentation: public\n representations:\n public: {access: public, disclosureProfile: public}", + "list:\n defaultRepresentation: public\n representations:\n public: {access: public, disclosureProfile: public}\n filters: []\n allowUnfiltered: true\n orderBy: [recordId, name]\n pagination: {defaultPageSize: 1, maximumPageSize: 10}", + ) + .replace("operationRefs: [read]", "operationRefs: [list]"); + let contract = RegistryContract::parse_yaml(&yaml).expect("strict list contract"); + let compiled = + compile_contract(&contract, &[observed_schema()], CompileProfile::Production) + .expect("cursor-safe list order"); + assert_eq!( + compiled.resources[0].operations[0].query.order_by, + ["name", "id"] + ); + } + + #[test] + fn optional_and_unsupported_cursor_order_columns_are_refused() { + let yaml = valid_contract() + .replace( + "read:\n defaultRepresentation: public\n representations:\n public: {access: public, disclosureProfile: public}", + "list:\n defaultRepresentation: public\n representations:\n public: {access: public, disclosureProfile: public}\n filters: []\n allowUnfiltered: true\n orderBy: [name]\n pagination: {defaultPageSize: 1, maximumPageSize: 10}", + ) + .replace("operationRefs: [read]", "operationRefs: [list]"); + let optional = yaml.replace( + "sourceColumn: name\n type: string\n sourceRequired: true", + "sourceColumn: name\n type: string\n sourceRequired: false", + ); + let optional = RegistryContract::parse_yaml(&optional).expect("strict list contract"); + let report = compile_contract(&optional, &[observed_schema()], CompileProfile::Production) + .expect_err("optional cursor order refused"); + assert!(report + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "list.order_property_optional")); + + let contract = RegistryContract::parse_yaml(&yaml).expect("strict list contract"); + let mut unsupported = observed_schema(); + unsupported.views[0] + .columns + .iter_mut() + .find(|column| column.name == "name") + .expect("name column") + .declared_type = "REAL".into(); + let report = compile_contract(&contract, &[unsupported], CompileProfile::Production) + .expect_err("unsupported cursor scalar refused"); + assert!(report + .diagnostics + .iter() + .any(|diagnostic| { diagnostic.code == "list.order_column_type_unsupported" })); + } + + #[test] + fn sqlite_view_nullable_metadata_does_not_override_required_order_contract() { + let yaml = valid_contract() + .replace( + "read:\n defaultRepresentation: public\n representations:\n public: {access: public, disclosureProfile: public}", + "list:\n defaultRepresentation: public\n representations:\n public: {access: public, disclosureProfile: public}\n filters: []\n allowUnfiltered: true\n orderBy: [name]\n pagination: {defaultPageSize: 1, maximumPageSize: 10}", + ) + .replace("operationRefs: [read]", "operationRefs: [list]"); + let contract = RegistryContract::parse_yaml(&yaml).expect("strict list contract"); + let mut observed = observed_schema(); + for column in &mut observed.views[0].columns { + column.nullable = true; + } + let compiled = compile_contract(&contract, &[observed], CompileProfile::Production) + .expect("SQLite view nullability cannot disprove the required source contract"); + assert_eq!( + compiled.resources[0].operations[0].query.order_by, + ["name", "id"] + ); + } + + #[test] + fn required_record_identifier_tie_breaker_is_included_in_order_cap() { + let yaml = valid_contract() + .replace( + "read:\n defaultRepresentation: public\n representations:\n public: {access: public, disclosureProfile: public}", + "list:\n defaultRepresentation: public\n representations:\n public: {access: public, disclosureProfile: public}\n filters: []\n allowUnfiltered: true\n orderBy: []\n pagination: {defaultPageSize: 1, maximumPageSize: 10}", + ) + .replace("operationRefs: [read]", "operationRefs: [list]"); + let base = RegistryContract::parse_yaml(&yaml).expect("strict list contract"); + + let compile_with_authored_order = |count: usize| { + let mut value = serde_json::to_value(&base).expect("contract serializes"); + let properties = value + .pointer_mut("/resources/0/properties") + .and_then(serde_json::Value::as_object_mut) + .expect("properties object"); + let template = properties.get("name").expect("name property").clone(); + let mut schema = observed_schema(); + for index in 0..count { + let property_name = format!("sort{index}"); + let column_name = format!("sort_{index}"); + let mut property = template.clone(); + property + .as_object_mut() + .expect("property object") + .insert("sourceColumn".into(), serde_json::json!(column_name)); + properties.insert(property_name, property); + schema.views[0].columns.push(crate::model::ObservedColumn { + name: column_name, + declared_type: "TEXT".into(), + nullable: false, + primary_key: false, + }); + } + *value + .pointer_mut("/resources/0/operations/list/orderBy") + .expect("order array") = serde_json::Value::Array( + (0..count) + .map(|index| serde_json::json!(format!("sort{index}"))) + .collect(), + ); + let contract = serde_json::from_value::(value) + .expect("strict generated contract"); + compile_contract(&contract, &[schema], CompileProfile::Production) + }; + + let at_cap = compile_with_authored_order(MAXIMUM_LIST_ORDER_KEYS - 1) + .expect("authored order plus tie-breaker fits the cap"); + assert_eq!( + at_cap.resources[0].operations[0].query.order_by.len(), + MAXIMUM_LIST_ORDER_KEYS + ); + let report = compile_with_authored_order(MAXIMUM_LIST_ORDER_KEYS) + .expect_err("the implicit tie-breaker cannot create cap plus one"); + assert!(report + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "list.order_bound_exceeded")); + } + + #[test] + fn classification_inventory_excludes_presentation_and_runtime_tuning() { + let yaml = valid_contract() + .replace( + "read:\n defaultRepresentation: public\n representations:\n public: {access: public, disclosureProfile: public}", + "list:\n defaultRepresentation: public\n representations:\n public: {access: public, disclosureProfile: public}\n filters: []\n allowUnfiltered: true\n orderBy: [name]\n pagination: {defaultPageSize: 1, maximumPageSize: 10}", + ) + .replace("operationRefs: [read]", "operationRefs: [list]"); + let contract = RegistryContract::parse_yaml(&yaml).expect("strict list contract"); + let compiled = + compile_contract(&contract, &[observed_schema()], CompileProfile::Production) + .expect("classification inventory compiles"); + let baseline = classification_inventory_digest(&compiled).expect("baseline digest"); + + let mut presentation_only = compiled.clone(); + presentation_only.registry_name = "Renamed registry".into(); + presentation_only.resources[0].title = "Renamed resource".into(); + presentation_only.resources[0].description = "Reworded description".into(); + presentation_only.resources[0].properties[0].label = "Renamed property".into(); + presentation_only.resources[0].properties[0].description = "Reworded property".into(); + let operation = &mut presentation_only.resources[0].operations[0]; + operation + .query + .pagination + .as_mut() + .expect("list pagination") + .maximum_page_size = 99; + operation.representations[0].schema_reference = "https://elsewhere.invalid/schema".into(); + operation.representations[0].semantic_model_reference = + "https://elsewhere.invalid/vocabulary".into(); + operation.representations[0].context_reference = "https://elsewhere.invalid/context".into(); + assert_eq!( + classification_inventory_digest(&presentation_only).expect("narrow digest"), + baseline + ); + + let mut source_changed = compiled.clone(); + source_changed.sources[0].expected_schema_fingerprint = + format!("sha256:{}", "b".repeat(64)); + assert_ne!( + classification_inventory_digest(&source_changed).expect("source digest"), + baseline + ); + + let mut classification_changed = compiled.clone(); + classification_changed.resources[0].properties[0] + .classification + .privacy = "identifying".into(); + assert_ne!( + classification_inventory_digest(&classification_changed) + .expect("classification digest"), + baseline + ); + + let mut semantic_changed = compiled.clone(); + semantic_changed.resources[0].properties[0].semantic_iri = + "https://example.invalid/changed-term".into(); + assert_ne!( + classification_inventory_digest(&semantic_changed).expect("semantic digest"), + baseline + ); + + let mut transform_changed = compiled.clone(); + transform_changed.resources[0].properties[0].transform = + Some(CompiledTransform::PartialString { + identifier: "partial-string:suffix:2".into(), + reveal: crate::contract::PartialStringReveal::Suffix, + characters: 2, + }); + assert_ne!( + classification_inventory_digest(&transform_changed).expect("transform digest"), + baseline + ); + + let mut transform_inventory_changed = compiled.clone(); + transform_inventory_changed.resources[0].operations[0].representations[0] + .transform_inventory + .push("partial-string:suffix:2".into()); + assert_ne!( + classification_inventory_digest(&transform_inventory_changed) + .expect("transform inventory digest"), + baseline + ); + + let mut access_changed = compiled.clone(); + access_changed.resources[0].operations[0].representations[0].access = + CompiledAccess::Protected { + scope: "registry:changed:read".into(), + purpose: None, + row_binding: None, + }; + assert_ne!( + classification_inventory_digest(&access_changed).expect("access digest"), + baseline + ); + + let mut disclosure_changed = compiled; + disclosure_changed.resources[0].operations[0].representations[0].disclosure_handling = + Handling::Internal; + assert_ne!( + classification_inventory_digest(&disclosure_changed).expect("disclosure digest"), + baseline + ); + } + + #[test] + fn stale_review_fails_production_but_remains_an_authoring_finding() { + let contract = RegistryContract::parse_yaml(valid_contract()).expect("strict contract"); + let mut governed = governed_files(); + let review = String::from_utf8( + governed + .get("governance/classification-review.yaml") + .expect("review") + .clone(), + ) + .expect("review text"); + let review = review + .lines() + .map(|line| { + if line.starts_with("classificationInventoryDigest:") { + "classificationInventoryDigest: sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + } else { + line + } + }) + .collect::>() + .join("\n") + + "\n"; + governed.insert( + "governance/classification-review.yaml".into(), + review.into_bytes(), + ); + let production = compile_contract_with_governed_files( + &contract, + &[observed_schema()], + CompileProfile::Production, + &governed, + ) + .expect_err("stale production review refused"); + assert!(production + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "classification.review_inventory_stale")); + + let authoring = compile_contract_with_governed_files( + &contract, + &[observed_schema()], + CompileProfile::Authoring, + &governed, + ) + .expect("authoring remains usable"); + assert!(authoring.classification_review.is_none()); + } + #[test] fn production_requires_an_explicitly_reviewed_codelist() { let contract = RegistryContract::parse_yaml(valid_contract()).expect("strict contract"); @@ -2411,12 +3414,30 @@ pub(crate) mod tests { .any(|item| item.code == "codelist.unreviewed")); } + #[test] + fn classification_rationale_is_part_of_the_governed_artifact_closure() { + let contract = RegistryContract::parse_yaml(valid_contract()).expect("strict contract"); + let mut governed = governed_files(); + governed.remove("governance/review-rationale"); + let report = compile_contract_with_governed_files( + &contract, + &[observed_schema()], + CompileProfile::Production, + &governed, + ) + .expect_err("missing review rationale refused"); + assert!(report.diagnostics.iter().any(|diagnostic| { + diagnostic.code == "contract.governed_file_missing" + && diagnostic.location == "governance/review-rationale" + })); + } + #[test] fn governed_query_bounds_cannot_exceed_product_ceilings() { let oversized_list = valid_contract().replace( - "read:\n access: public\n disclosureProfile: public", + "read:\n defaultRepresentation: public\n representations:\n public: {access: public, disclosureProfile: public}", &format!( - "list:\n access: public\n disclosureProfile: public\n filters: []\n allowUnfiltered: true\n orderBy: [name]\n pagination: {{defaultPageSize: 1, maximumPageSize: {}}}", + "list:\n defaultRepresentation: public\n representations:\n public: {{access: public, disclosureProfile: public}}\n filters: []\n allowUnfiltered: true\n orderBy: [name]\n pagination: {{defaultPageSize: 1, maximumPageSize: {}}}", MAXIMUM_LIST_PAGE_SIZE + 1 ), ); @@ -2434,9 +3455,9 @@ pub(crate) mod tests { .any(|item| item.code == "list.pagination_invalid")); let oversized_lookup = valid_contract().replace( - "read:\n access: public\n disclosureProfile: public", + "read:\n defaultRepresentation: public\n representations:\n public: {access: public, disclosureProfile: public}", &format!( - "lookups:\n - id: by-name\n access: {{scope: registry:records:lookup}}\n requestBody:\n maximumBytes: {}\n selectors:\n name: {{sourceColumn: name, type: string, maximumBytes: 32}}\n disclosureProfile: public", + "lookups:\n - id: by-name\n requestBody:\n maximumBytes: {}\n selectors:\n name: {{sourceColumn: name, type: string, maximumBytes: 32}}\n defaultRepresentation: public\n representations:\n public: {{access: {{scope: registry:records:lookup}}, disclosureProfile: public}}", MAXIMUM_LOOKUP_REQUEST_BODY_BYTES + 1 ), ); @@ -2512,9 +3533,44 @@ pub(crate) mod tests { } assert_refused(&parse_value(disclosures_value), "disclosure.bound_exceeded"); + let mut representations_value = serde_json::to_value(&base).expect("contract serializes"); + let representations = representations_value + .pointer_mut("/resources/0/operations/read/representations") + .and_then(serde_json::Value::as_object_mut) + .expect("representations object"); + let representation = representations + .get("public") + .expect("public representation") + .clone(); + for index in 1..=MAXIMUM_REPRESENTATIONS_PER_OPERATION { + representations.insert(format!("profile-{index}"), representation.clone()); + } + assert_refused( + &parse_value(representations_value), + "representation.bound_exceeded", + ); + + let mut registry_representations_value = + serde_json::to_value(&base).expect("contract serializes"); + let registry_representations = registry_representations_value + .pointer_mut("/resources/0/operations/read/representations") + .and_then(serde_json::Value::as_object_mut) + .expect("representations object"); + let representation = registry_representations + .get("public") + .expect("public representation") + .clone(); + for index in 1..=MAXIMUM_REPRESENTATION_EXECUTORS_PER_REGISTRY { + registry_representations.insert(format!("profile-{index}"), representation.clone()); + } + assert_refused( + &parse_value(registry_representations_value), + "representation.registry_bound_exceeded", + ); + let list_yaml = valid_contract().replace( - "read:\n access: public\n disclosureProfile: public", - "list:\n access: public\n disclosureProfile: public\n filters: []\n allowUnfiltered: true\n orderBy: [name]\n pagination: {defaultPageSize: 1, maximumPageSize: 1}", + "read:\n defaultRepresentation: public\n representations:\n public: {access: public, disclosureProfile: public}", + "list:\n defaultRepresentation: public\n representations:\n public: {access: public, disclosureProfile: public}\n filters: []\n allowUnfiltered: true\n orderBy: [name]\n pagination: {defaultPageSize: 1, maximumPageSize: 1}", ); let list_contract = RegistryContract::parse_yaml(&list_yaml).expect("strict list contract"); let mut filters_value = serde_json::to_value(&list_contract).expect("contract serializes"); @@ -2542,6 +3598,149 @@ pub(crate) mod tests { assert_refused(&parse_value(order_value), "list.order_bound_exceeded"); } + #[test] + fn an_operation_with_a_public_representation_requires_a_public_default() { + let contract = RegistryContract::parse_yaml(&valid_contract().replace( + "defaultRepresentation: public\n representations:\n public: {access: public, disclosureProfile: public}", + "defaultRepresentation: protected\n representations:\n public: {access: public, disclosureProfile: public}\n protected: {access: {scope: registry:record:protected}, disclosureProfile: public}", + )) + .expect("strict contract"); + let report = compile_contract(&contract, &[observed_schema()], CompileProfile::Production) + .expect_err("a hidden protected default is refused"); + assert!(report + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "representation.public_default_required")); + } + + #[test] + fn one_operation_compiles_finite_representations_with_distinct_handling() { + let contract = RegistryContract::parse_yaml(&governed_representations_contract()) + .expect("strict representation contract"); + let compiled = + compile_contract(&contract, &[observed_schema()], CompileProfile::Production) + .expect("representations compile"); + let resource = &compiled.resources[0]; + assert_eq!(resource.operations.len(), 1); + let operation = &resource.operations[0]; + assert_eq!(operation.identifier, "record.read"); + assert_eq!(operation.default_representation, "limited"); + assert_eq!(operation.representations.len(), 2); + let limited = &operation.representations[0]; + let full = &operation.representations[1]; + assert_eq!(limited.id, "limited"); + assert_eq!(limited.disclosure_handling, Handling::Confidential); + assert_eq!(limited.processing_handling, Handling::Restricted); + assert_eq!(full.processing_handling, Handling::Restricted); + assert_eq!( + limited.transform_inventory, + ["maskedName=partial-string:suffix:4"] + ); + assert_ne!(limited.schema_reference, full.schema_reference); + assert_eq!( + limited.projected_columns, + ["id", "revision", "lifecycle", "recorded_at", "name"] + ); + assert!(resource + .properties + .iter() + .all(|property| property.source_required)); + let account = resource + .column_accounting + .iter() + .find(|account| account.column == "name") + .expect("source account"); + assert_eq!(account.classification.handling, Handling::Restricted); + assert!(account.uses.contains(&ColumnUse::Property("name".into()))); + assert!(account + .uses + .contains(&ColumnUse::Property("maskedName".into()))); + } + + #[test] + fn transformed_and_multiply_bound_columns_require_explicit_review() { + let yaml = governed_representations_contract().replace( + " sourceColumnClassifications:\n name: {privacy: identifying, institutional: restricted, handling: restricted, status: reviewed}\n", + " sourceColumnClassifications: {}\n", + ); + let contract = RegistryContract::parse_yaml(&yaml).expect("strict contract"); + let report = compile_contract(&contract, &[observed_schema()], CompileProfile::Production) + .expect_err("implicit source classification is refused"); + assert!(report.diagnostics.iter().any(|diagnostic| { + diagnostic.code == "classification.column_explicit_review_required" + })); + } + + #[test] + fn representation_default_and_transform_parameters_fail_closed() { + let invalid_default = governed_representations_contract().replace( + "defaultRepresentation: limited", + "defaultRepresentation: absent", + ); + let contract = RegistryContract::parse_yaml(&invalid_default).expect("strict contract"); + let report = compile_contract(&contract, &[observed_schema()], CompileProfile::Production) + .expect_err("unknown default refused"); + assert!(report + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "representation.default_invalid")); + + for characters in [0, MAXIMUM_PARTIAL_STRING_CHARACTERS + 1] { + let yaml = governed_representations_contract() + .replace("characters: 4", &format!("characters: {characters}")); + let contract = RegistryContract::parse_yaml(&yaml).expect("strict contract"); + let report = + compile_contract(&contract, &[observed_schema()], CompileProfile::Production) + .expect_err("out-of-profile transform refused"); + assert!(report.diagnostics.iter().any(|diagnostic| { + diagnostic.code == "transform.partial_string_characters_invalid" + })); + } + } + + #[test] + fn public_masked_representation_cannot_process_restricted_source() { + let yaml = governed_representations_contract() + .replace( + "classification: {privacy: partially-revealed-identifying, institutional: confidential, handling: confidential, status: reviewed}", + "classification: {privacy: partially-revealed-identifying, institutional: public, handling: public, status: reviewed}", + ) + .replace( + "limited:\n access: {scope: registry:records:limited}", + "limited:\n access: public", + ); + let contract = RegistryContract::parse_yaml(&yaml).expect("strict contract"); + let report = compile_contract(&contract, &[observed_schema()], CompileProfile::Production) + .expect_err("public processing of restricted raw source refused"); + assert!(report + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "access.public_nonpublic_forbidden")); + } + + #[test] + fn date_precision_is_typed_and_closed() { + let yaml = governed_representations_contract() + .replace("type: string\n sourceRequired: true\n semanticTerm: local:maskedName\n classification: {privacy: partially-revealed-identifying, institutional: confidential, handling: confidential, status: reviewed}\n transform: {kind: partial-string, reveal: suffix, characters: 4}", "type: year-month\n sourceRequired: true\n semanticTerm: local:maskedName\n classification: {privacy: partially-revealed-identifying, institutional: confidential, handling: confidential, status: reviewed}\n transform: {kind: date-precision, sourceType: date-time, precision: year-month}"); + let contract = RegistryContract::parse_yaml(&yaml).expect("strict date transform"); + let compiled = + compile_contract(&contract, &[observed_schema()], CompileProfile::Production) + .expect("typed date precision compiles"); + assert_eq!( + compiled.resources[0].properties[1].data_type, + DataType::YearMonth + ); + + let invalid = yaml.replace("type: year-month", "type: year"); + let contract = RegistryContract::parse_yaml(&invalid).expect("strict contract"); + let report = compile_contract(&contract, &[observed_schema()], CompileProfile::Production) + .expect_err("precision/output mismatch refused"); + assert!(report + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "transform.output_type_invalid")); + } + #[test] fn public_operation_cannot_process_nonpublic_columns() { let yaml = valid_contract().replace( @@ -2573,7 +3772,7 @@ pub(crate) mod tests { &contract, &[observed_schema()], CompileProfile::Production, - &governed_files(), + &governed_files_for(&contract), ) .expect("protected row-bound compilation"); let operation = &compiled.resources[0].operations[0]; @@ -2581,7 +3780,7 @@ pub(crate) mod tests { let CompiledAccess::Protected { row_binding: Some(binding), .. - } = &operation.access + } = &operation.representations[0].access else { panic!("row-bound protected access expected"); }; @@ -2639,19 +3838,31 @@ pub(crate) mod tests { } pub(crate) fn governed_files() -> GovernedFileSet { - [ + let contract = RegistryContract::parse_yaml(valid_contract()).expect("strict contract"); + governed_files_for(&contract) + } + + fn governed_files_for(contract: &RegistryContract) -> GovernedFileSet { + let compiled = compile_contract(contract, &[observed_schema()], CompileProfile::Production) + .expect("inventory compiles"); + let inventory_digest = + classification_inventory_digest(&compiled).expect("inventory digest"); + let review = format!( + "apiVersion: relay.registrystack.org/classification-review/v1\nkind: ClassificationReview\nregistryIdentifier: urn:example:registry:records\nclassificationInventoryDigest: {inventory_digest}\nmethod: manual\nreviewer: urn:example:authority\nreviewDate: 2026-08-10\nstatus: reviewed\nrationaleRef: governance/review-rationale\n" + ); + let mut files = [ ( "governance/identifier-lifecycle.yaml", "status: reviewed\npolicy: identifiers are not reassigned\n", ), - ( - "governance/classification-review.yaml", - "status: reviewed\nreviewer: registry-authority\n", - ), ( "governance/legal-basis.yaml", "status: reviewed\nbasis: statutory-publication\n", ), + ( + "governance/review-rationale", + "reviewed classification and representation design\n", + ), ( "governance/processing.dpv.yaml", "status: reviewed\nprofile: https://w3id.org/dpv/2.3\n", @@ -2663,7 +3874,32 @@ pub(crate) mod tests { ] .into_iter() .map(|(path, content)| (path.into(), content.as_bytes().to_vec())) - .collect() + .collect::(); + files.insert( + "governance/classification-review.yaml".into(), + review.into_bytes(), + ); + files + } + + fn governed_representations_contract() -> String { + valid_contract() + .replace( + " sourceColumnClassifications: {}", + " sourceColumnClassifications:\n name: {privacy: identifying, institutional: restricted, handling: restricted, status: reviewed}", + ) + .replace( + " semanticTerm: local:name\n disclosureProfiles: {public: {properties: [name]}}", + " semanticTerm: local:name\n classification: {privacy: identifying, institutional: restricted, handling: restricted, status: reviewed}\n maskedName:\n label: Masked name\n description: Partially revealed Record name\n sourceColumn: name\n type: string\n sourceRequired: true\n semanticTerm: local:maskedName\n classification: {privacy: partially-revealed-identifying, institutional: confidential, handling: confidential, status: reviewed}\n transform: {kind: partial-string, reveal: suffix, characters: 4}\n disclosureProfiles:\n limited: {properties: [maskedName]}\n full: {properties: [name]}", + ) + .replace( + " read:\n defaultRepresentation: public\n representations:\n public: {access: public, disclosureProfile: public}", + " read:\n defaultRepresentation: limited\n representations:\n limited:\n access: {scope: registry:records:limited}\n disclosureProfile: limited\n full:\n access: {scope: registry:records:full}\n disclosureProfile: full", + ) + .replace( + "metadataVisibility: {service: public, resources: public, semantics: public, classifications: public, processing: public}", + "metadataVisibility: {service: public, resources: operation-bound, semantics: operation-bound, classifications: operation-bound, processing: operation-bound}", + ) } pub(crate) fn valid_contract() -> &'static str { @@ -2712,8 +3948,9 @@ resources: disclosureProfiles: {public: {properties: [name]}} operations: read: - access: public - disclosureProfile: public + defaultRepresentation: public + representations: + public: {access: public, disclosureProfile: public} processingDescriptions: - id: statutory-publication operationRefs: [read] diff --git a/crates/registry-relay-v2/src/contract.rs b/crates/registry-relay-v2/src/contract.rs index 7de7fdd45..ea116dc15 100644 --- a/crates/registry-relay-v2/src/contract.rs +++ b/crates/registry-relay-v2/src/contract.rs @@ -322,6 +322,8 @@ pub struct PropertyDefinition { pub source_required: bool, pub semantic_term: String, #[serde(default)] + pub transform: Option, + #[serde(default)] pub classification: ClassificationPartial, } @@ -333,9 +335,46 @@ pub enum DataType { Integer, Date, DateTime, + Year, + YearMonth, ControlledCode, } +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "kebab-case")] +pub enum TransformDefinition { + PartialString { + reveal: PartialStringReveal, + characters: u16, + }, + DatePrecision { + #[serde(rename = "sourceType")] + source_type: DateInputType, + precision: DatePrecision, + }, +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum PartialStringReveal { + Prefix, + Suffix, +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum DateInputType { + Date, + DateTime, +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum DatePrecision { + Year, + YearMonth, +} + #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] #[serde(deny_unknown_fields, rename_all = "camelCase")] pub struct DisclosureProfile { @@ -356,8 +395,8 @@ pub struct Operations { #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] #[serde(deny_unknown_fields, rename_all = "camelCase")] pub struct ListOperation { - pub access: AccessRule, - pub disclosure_profile: String, + pub default_representation: String, + pub representations: OrderedMap, #[serde(default)] pub filters: Vec, pub allow_unfiltered: bool, @@ -368,19 +407,66 @@ pub struct ListOperation { #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] #[serde(deny_unknown_fields, rename_all = "camelCase")] pub struct RecordOperation { - pub access: AccessRule, - pub disclosure_profile: String, + pub default_representation: String, + pub representations: OrderedMap, } #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] #[serde(deny_unknown_fields, rename_all = "camelCase")] pub struct LookupOperation { pub id: String, - pub access: AccessRule, pub request_body: LookupRequestBody, + pub default_representation: String, + pub representations: OrderedMap, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct RepresentationDefinition { + pub access: AccessRule, pub disclosure_profile: String, } +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ClassificationReviewDocument { + pub api_version: String, + pub kind: String, + pub registry_identifier: String, + pub classification_inventory_digest: String, + pub method: IdentificationMethod, + pub reviewer: String, + pub review_date: String, + pub status: ReviewStatus, + pub rationale_ref: String, + #[serde(default)] + pub generated_identification: Option, +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum IdentificationMethod { + Generated, + Imported, + Manual, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct GeneratedIdentificationBinding { + pub report_ref: String, + pub report_digest: String, + pub rule_pack: RulePackBinding, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct RulePackBinding { + pub id: String, + pub version: String, + pub digest: String, +} + #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] #[serde(untagged)] pub enum AccessRule { @@ -789,4 +875,13 @@ disclosureProfiles: {} ); } } + + #[test] + fn legacy_single_profile_operation_shape_is_not_accepted() { + let yaml = crate::compiler::tests::valid_contract().replace( + " defaultRepresentation: public\n representations:\n public: {access: public, disclosureProfile: public}", + " access: public\n disclosureProfile: public", + ); + assert!(RegistryContract::parse_yaml(&yaml).is_err()); + } } diff --git a/crates/registry-relay-v2/src/cursor.rs b/crates/registry-relay-v2/src/cursor.rs index c9f25c549..c1a12920c 100644 --- a/crates/registry-relay-v2/src/cursor.rs +++ b/crates/registry-relay-v2/src/cursor.rs @@ -13,7 +13,7 @@ use sha2::Sha256; use thiserror::Error; use zeroize::Zeroizing; -const CURSOR_VERSION: u8 = 1; +const CURSOR_VERSION: u8 = 2; const MAX_CURSOR_BYTES: usize = 8 * 1024; const MAC_BYTES: usize = 32; @@ -28,6 +28,9 @@ pub struct CursorPayload { pub contract_revision: String, pub source_revision: String, pub operation: String, + pub representation: String, + pub disclosure_profile: String, + pub transforms_digest: String, pub filters_digest: String, pub selected_fields_digest: String, pub authorization_digest: String, @@ -56,6 +59,9 @@ pub enum CursorValue { #[derive(Clone, Debug, PartialEq, Eq)] pub struct CursorBindings { + pub representation: String, + pub disclosure_profile: String, + pub transforms_digest: String, pub filters_digest: String, pub selected_fields_digest: String, pub authorization_digest: String, @@ -78,6 +84,9 @@ impl CursorPayload { contract_revision, source_revision, operation, + representation: bindings.representation, + disclosure_profile: bindings.disclosure_profile, + transforms_digest: bindings.transforms_digest, filters_digest: bindings.filters_digest, selected_fields_digest: bindings.selected_fields_digest, authorization_digest: bindings.authorization_digest, @@ -207,6 +216,9 @@ pub fn require_same_request( if cursor.contract_revision != request.contract_revision || cursor.source_revision != request.source_revision || cursor.operation != request.operation + || cursor.representation != request.representation + || cursor.disclosure_profile != request.disclosure_profile + || cursor.transforms_digest != request.transforms_digest || cursor.filters_digest != request.filters_digest || cursor.selected_fields_digest != request.selected_fields_digest || cursor.authorization_digest != request.authorization_digest @@ -236,6 +248,9 @@ mod tests { "sha256:source".to_owned(), "resource.list".to_owned(), CursorBindings { + representation: "public".to_owned(), + disclosure_profile: "public".to_owned(), + transforms_digest: "sha256:transforms".to_owned(), filters_digest: "sha256:filters".to_owned(), selected_fields_digest: "sha256:fields".to_owned(), authorization_digest: "sha256:authorization".to_owned(), @@ -273,4 +288,25 @@ mod tests { Err(CursorError::Mismatch) ); } + + #[test] + fn cursor_cannot_cross_representation_disclosure_or_transform_contexts() { + let alterations: [fn(&mut CursorPayload); 3] = [ + |payload: &mut CursorPayload| payload.representation = "caseworker".to_owned(), + |payload: &mut CursorPayload| { + payload.disclosure_profile = "caseworker".to_owned(); + }, + |payload: &mut CursorPayload| { + payload.transforms_digest = "sha256:other-transforms".to_owned(); + }, + ]; + for alter in alterations { + let mut request = payload(); + alter(&mut request); + assert_eq!( + require_same_request(&payload(), &request), + Err(CursorError::Mismatch) + ); + } + } } diff --git a/crates/registry-relay-v2/src/diff.rs b/crates/registry-relay-v2/src/diff.rs index 0febccfe4..3efbe0a8d 100644 --- a/crates/registry-relay-v2/src/diff.rs +++ b/crates/registry-relay-v2/src/diff.rs @@ -50,10 +50,14 @@ pub enum ChangeClass { PropertyAdded, PropertyRemoved, PropertyMeaningChanged, + TransformationChanged, HandlingRelaxed, HandlingTightened, OperationAdded, OperationRemoved, + RepresentationAdded, + RepresentationRemoved, + DefaultRepresentationChanged, DisclosureExpanded, DisclosureNarrowed, DisclosureProfileChanged, @@ -81,6 +85,7 @@ pub enum ChangeClass { MetadataVisibilityTightened, SemanticAlignmentChanged, ClassificationChanged, + ClassificationReviewChanged, ProcessingChanged, GovernedFileChanged, } @@ -166,6 +171,15 @@ pub fn diff_registries( "the pinned external semantic alignment set changed", ); } + if previous.classification_review != current.classification_review { + push( + &mut changes, + ChangeClass::ClassificationReviewChanged, + ChangeImpact::Breaking, + "classifications.provenanceRef".into(), + "the reviewed classification binding, inventory digest, method, or identification evidence changed", + ); + } let before_governed = previous .governed_files .iter() @@ -301,6 +315,15 @@ fn diff_resource( "a property binding, meaning, datatype, codelist, or requiredness changed", ); } + if before.transform != after.transform { + push( + changes, + ChangeClass::TransformationChanged, + ChangeImpact::Breaking, + format!("{location}.transform"), + "the closed transformation kind or parameters changed", + ); + } let before_handling = before.classification.handling; let after_handling = after.classification.handling; if after_handling < before_handling { @@ -379,50 +402,54 @@ fn diff_operation( location: &str, changes: &mut Vec, ) { - if previous.disclosure_profile != current.disclosure_profile { + if previous.default_representation != current.default_representation { push( changes, - ChangeClass::DisclosureProfileChanged, + ChangeClass::DefaultRepresentationChanged, ChangeImpact::Breaking, - format!("{location}.disclosureProfile"), - "the named disclosure profile changed and requires review", + format!("{location}.defaultRepresentation"), + "the representation selected when the caller omits an explicit choice changed", ); } - let previous_properties = previous - .selectable_properties + let before_representations = previous + .representations .iter() - .map(String::as_str) - .collect::>(); - let current_properties = current - .selectable_properties + .map(|representation| (representation.id.as_str(), representation)) + .collect::>(); + let after_representations = current + .representations .iter() - .map(String::as_str) - .collect::>(); - if current_properties - .difference(&previous_properties) - .next() - .is_some() - { - push( - changes, - ChangeClass::DisclosureExpanded, - ChangeImpact::Widening, - format!("{location}.disclosureProfile"), - "the maximum disclosure property set expanded", - ); - } - if previous_properties - .difference(¤t_properties) - .next() - .is_some() + .map(|representation| (representation.id.as_str(), representation)) + .collect::>(); + for id in before_representations + .keys() + .chain(after_representations.keys()) + .collect::>() { - push( - changes, - ChangeClass::DisclosureNarrowed, - ChangeImpact::Narrowing, - format!("{location}.disclosureProfile"), - "the maximum disclosure property set narrowed", - ); + let representation_location = format!("{location}.representations.{id}"); + match ( + before_representations.get(*id), + after_representations.get(*id), + ) { + (None, Some(_)) => push( + changes, + ChangeClass::RepresentationAdded, + ChangeImpact::Widening, + representation_location, + "a callable representation was added to the operation", + ), + (Some(_), None) => push( + changes, + ChangeClass::RepresentationRemoved, + ChangeImpact::Breaking, + representation_location, + "a callable representation was removed from the operation", + ), + (Some(before), Some(after)) => { + diff_representation(before, after, &representation_location, changes); + } + (None, None) => unreachable!(), + } } let before_filters = previous @@ -517,6 +544,79 @@ fn diff_operation( location, changes, ); +} + +fn diff_representation( + previous: &crate::model::CompiledRepresentation, + current: &crate::model::CompiledRepresentation, + location: &str, + changes: &mut Vec, +) { + if previous.disclosure_profile != current.disclosure_profile { + push( + changes, + ChangeClass::DisclosureProfileChanged, + ChangeImpact::Breaking, + format!("{location}.disclosureProfile"), + "the named disclosure profile changed and requires review", + ); + } + let previous_properties = previous + .selectable_properties + .iter() + .map(String::as_str) + .collect::>(); + let current_properties = current + .selectable_properties + .iter() + .map(String::as_str) + .collect::>(); + if current_properties + .difference(&previous_properties) + .next() + .is_some() + { + push( + changes, + ChangeClass::DisclosureExpanded, + ChangeImpact::Widening, + format!("{location}.disclosureProfile"), + "the maximum disclosure property set expanded", + ); + } + if previous_properties + .difference(¤t_properties) + .next() + .is_some() + { + push( + changes, + ChangeClass::DisclosureNarrowed, + ChangeImpact::Narrowing, + format!("{location}.disclosureProfile"), + "the maximum disclosure property set narrowed", + ); + } + if previous.transform_inventory != current.transform_inventory { + push( + changes, + ChangeClass::TransformationChanged, + ChangeImpact::Breaking, + format!("{location}.transforms"), + "the representation transformation inventory changed", + ); + } + if previous.processing_handling != current.processing_handling + || previous.disclosure_handling != current.disclosure_handling + { + push( + changes, + ChangeClass::ClassificationChanged, + ChangeImpact::Breaking, + format!("{location}.handling"), + "the representation processing or disclosure handling floor changed", + ); + } diff_access(&previous.access, ¤t.access, location, changes); } @@ -846,6 +946,44 @@ mod tests { .any(|change| change.class == ChangeClass::ProcessingChanged)); } + #[test] + fn representations_transforms_defaults_and_review_bindings_are_reported() { + let previous = compiled(); + let mut current = previous.clone(); + let operation = &mut current.resources[0].operations[0]; + operation.representations[0] + .transform_inventory + .push("partial-string:suffix:4".into()); + let mut alternate = operation.representations[0].clone(); + alternate.id = "alternate".into(); + operation.representations.push(alternate); + operation.default_representation = "alternate".into(); + current + .classification_review + .as_mut() + .expect("compiled production review") + .classification_inventory_digest = format!("sha256:{}", "a".repeat(64)); + + let report = diff_registries(&previous, ¤t); + for class in [ + ChangeClass::TransformationChanged, + ChangeClass::RepresentationAdded, + ChangeClass::DefaultRepresentationChanged, + ChangeClass::ClassificationReviewChanged, + ] { + assert!( + report.changes.iter().any(|change| change.class == class), + "missing {class:?}" + ); + } + + let reverse = diff_registries(¤t, &previous); + assert!(reverse + .changes + .iter() + .any(|change| change.class == ChangeClass::RepresentationRemoved)); + } + #[test] fn governed_sidecar_digest_changes_are_reported() { let previous = compiled(); diff --git a/crates/registry-relay-v2/src/fixtures.rs b/crates/registry-relay-v2/src/fixtures.rs index 394b62c4a..f665a6b88 100644 --- a/crates/registry-relay-v2/src/fixtures.rs +++ b/crates/registry-relay-v2/src/fixtures.rs @@ -228,10 +228,20 @@ pub fn compile_fixture_plan( ); } if let Some(operation) = operation { - if matches!(operation.access, CompiledAccess::Protected { .. }) - && step.expect.status == 200 - && step.authorization_fixture.is_none() - { + let representation_identifier = step + .request + .query + .get("representation") + .and_then(Value::as_str) + .unwrap_or(&operation.default_representation); + let protected = operation + .representations + .iter() + .find(|representation| representation.id == representation_identifier) + .is_some_and(|representation| { + matches!(representation.access, CompiledAccess::Protected { .. }) + }); + if protected && step.expect.status == 200 && step.authorization_fixture.is_none() { diagnostic( &mut diagnostics, "fixture.authorization_missing", diff --git a/crates/registry-relay-v2/src/identification.rs b/crates/registry-relay-v2/src/identification.rs new file mode 100644 index 000000000..d284b41a2 --- /dev/null +++ b/crates/registry-relay-v2/src/identification.rs @@ -0,0 +1,1741 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Deterministic, schema-only Relay identification and classification-review binding. +//! +//! This module accepts only the governed contract and [`ObservedSourceSchema`] +//! metadata. It has no database, filesystem, network, or source-row interface. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Component, Path}; + +use chrono::NaiveDate; +use registry_platform_canonical_json::{canonicalize_json, parse_json_strict}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use thiserror::Error; + +use crate::contract::{ + AccessRule, AuthorityRowBinding, ClassificationReviewDocument, GeneratedIdentificationBinding, + Handling, IdentificationMethod, RegistryContract, ReviewStatus, RulePackBinding, +}; +use crate::model::{ + ColumnUse, CompiledAccess, CompiledOperation, CompiledRegistry, CompiledRepresentation, + CompiledResource, EffectiveClassification, ObservedColumn, ObservedSourceSchema, OperationKind, +}; + +pub const IDENTIFICATION_REPORT_PATH: &str = "reports/identification-report.json"; +pub const CLASSIFICATION_INVENTORY_REPORT_PATH: &str = "reports/classification-inventory.json"; +pub const REPRESENTATION_REPORT_PATH: &str = "reports/representation-report.json"; +pub const CONTEXTUAL_REVIEW_FINDINGS_PATH: &str = "reports/contextual-review-findings.json"; +pub const CLASSIFICATION_REVIEW_STARTER_PATH: &str = + "governance/classification-review-starter.yaml"; +pub const REVIEWED_IDENTIFICATION_REPORT_PATH: &str = "reports/identification-report.json"; + +const REPORT_API_VERSION: &str = "relay.registrystack.org/identification-report/v1"; +const REPORT_KIND: &str = "IdentificationReport"; +const REVIEW_API_VERSION: &str = "relay.registrystack.org/classification-review/v1"; +const REVIEW_KIND: &str = "ClassificationReview"; +const CORE_PACK_DIGEST: &str = + "sha256:5ad3abd1615c409741c190ff17c4ad8cf31db88dfe49c9bbddf47a2e12896fa2"; +const CORE_PACK_BYTES: &[u8] = include_bytes!("../assets/identification/core-pack-v1.json"); +const MAXIMUM_PACK_BYTES: usize = 64 * 1024; +const MAXIMUM_RULES: usize = 128; +const MAXIMUM_CONDITIONS_PER_RULE: usize = 8; +const MAXIMUM_SOURCES: usize = 256; +const MAXIMUM_VIEWS: usize = 10_000; +const MAXIMUM_COLUMNS: usize = 100_000; +const MAXIMUM_REVIEW_TEXT_BYTES: usize = 512; + +#[derive(Clone, Debug, Error, PartialEq, Eq)] +pub enum IdentificationError { + #[error("the embedded identification pack digest does not match its pin")] + PackDigestMismatch, + #[error("the embedded identification pack is invalid")] + PackInvalid, + #[error("the observed schema exceeds the identification bounds")] + InputTooLarge, + #[error("the identification artifact could not be canonicalized")] + Canonicalization, + #[error("the classification review could not be rendered")] + ReviewRender, + #[error("the classification review is not valid strict YAML")] + ReviewParse, + #[error("the classification inventory digest is invalid")] + InventoryDigestInvalid, +} + +impl IdentificationError { + /// A categorical failure that cannot expose schema names or source values. + pub fn safe_message(&self) -> &'static str { + match self { + Self::PackDigestMismatch => { + "the embedded identification pack digest does not match its pin" + } + Self::PackInvalid => "the embedded identification pack is invalid", + Self::InputTooLarge => "the observed schema exceeds the identification bounds", + Self::Canonicalization => "the identification artifact could not be canonicalized", + Self::ReviewRender => "the classification review could not be rendered", + Self::ReviewParse => "the classification review is not valid strict YAML", + Self::InventoryDigestInvalid => "the classification inventory digest is invalid", + } + } +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct IdentificationReport { + pub api_version: String, + pub kind: String, + pub registry_identifier: String, + pub observed_schema_digest: String, + pub rule_pack: RulePackBinding, + pub privacy_candidate_vocabulary: CandidateVocabulary, + pub candidates: Vec, + pub diagnostics: Vec, +} + +/// The local vocabulary used by an identification pack for review candidates. +/// +/// Candidate terms are never asserted to belong to the registry's configured +/// privacy scheme. An institutional reviewer must map or replace them before +/// accepting the governed classification inventory. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct CandidateVocabulary { + pub scheme: String, + pub version: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct CandidateTerm { + pub scheme: String, + pub version: String, + pub term: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct IdentificationCandidate { + pub source: String, + pub view: String, + pub source_column: String, + pub suggested_property: Option, + pub suggested_semantic_term: Option, + pub suggested_role: Option, + pub suggested_privacy: Vec, + pub matched_rules: Vec, + pub rule_pack: RulePackBinding, + pub confidence: CategoricalConfidence, + pub status: IdentificationStatus, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct MatchedRule { + pub id: String, + pub version: String, + pub family: RuleFamily, +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "kebab-case")] +pub enum RuleFamily { + AdministrativeCodes, + Codelists, + Columns, + Contact, + GeographicCodes, + Identifiers, + Lifecycle, + PersonReferences, + Revisions, + Times, +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "kebab-case")] +pub enum TechnicalRole { + AdministrativeCode, + Codelist, + EmailAddress, + GeographicCode, + Identifier, + LifecycleState, + PersonReference, + Property, + RecordedTime, + RecordIdentifier, + RevisionIdentifier, + TelephoneNumber, +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum CategoricalConfidence { + Exact, + Strong, + Weak, + Conflict, +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum IdentificationStatus { + Suggested, + Uncertain, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct IdentificationDiagnostic { + pub severity: IdentificationDiagnosticSeverity, + pub code: String, + pub source: String, + pub view: String, + pub source_column: String, + pub message: String, +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum IdentificationDiagnosticSeverity { + Warning, +} + +/// Identify every observed column using the single embedded technical pack. +/// +/// Input order is deliberately erased before digesting and matching. Authored +/// property names, semantic terms, codelists, Registry Core roles, filters, +/// selectors, ordering, and row bindings are the only contextual hints. +pub fn identify_contract( + contract: &RegistryContract, + observed: &[ObservedSourceSchema], +) -> Result { + ensure_observation_bounds(observed)?; + let pack = load_core_pack(CORE_PACK_BYTES, CORE_PACK_DIGEST)?; + let rule_pack = pack.reference(CORE_PACK_DIGEST); + let privacy_candidate_vocabulary = pack.privacy_candidate_vocabulary.clone(); + let normalized_observation = normalized_observation(observed); + let observed_schema_digest = digest_serializable(&normalized_observation)?; + let hints = authored_hints(contract); + let mut candidates = Vec::new(); + let mut diagnostics = Vec::new(); + + for schema in &normalized_observation { + for view in &schema.views { + for column in &view.columns { + let hint = hints.get(&( + schema.source.clone(), + view.name.clone(), + column.name.clone(), + )); + let candidate = + identify_column(&pack, &rule_pack, &schema.source, &view.name, column, hint); + if candidate.status == IdentificationStatus::Uncertain { + diagnostics.push(IdentificationDiagnostic { + severity: IdentificationDiagnosticSeverity::Warning, + code: "identification.candidate_conflict".into(), + source: schema.source.clone(), + view: view.name.clone(), + source_column: column.name.clone(), + message: + "credible schema-only rules conflict; institutional review is required" + .into(), + }); + } + candidates.push(candidate); + } + } + } + + Ok(IdentificationReport { + api_version: REPORT_API_VERSION.into(), + kind: REPORT_KIND.into(), + registry_identifier: contract.registry.registry_identifier.clone(), + observed_schema_digest, + rule_pack, + privacy_candidate_vocabulary, + candidates, + diagnostics, + }) +} + +/// Render the exact deterministic bytes used for the generated report digest. +pub fn render_identification_report( + report: &IdentificationReport, +) -> Result, IdentificationError> { + let value = serde_json::to_value(report).map_err(|_| IdentificationError::Canonicalization)?; + canonicalize_json(&value).map_err(|_| IdentificationError::Canonicalization) +} + +pub fn identification_report_digest( + report: &IdentificationReport, +) -> Result { + Ok(sha256(&render_identification_report(report)?)) +} + +/// Verify and return the identity of the one embedded identification pack. +pub fn core_pack_reference() -> Result { + Ok(load_core_pack(CORE_PACK_BYTES, CORE_PACK_DIGEST)?.reference(CORE_PACK_DIGEST)) +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ClassificationInventoryReport { + pub api_version: String, + pub kind: String, + pub registry_identifier: String, + pub classification_inventory_digest: String, + pub resources: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ResourceClassificationInventory { + pub resource: String, + pub source: String, + pub view: String, + pub source_columns: Vec, + pub properties: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct SourceColumnClassificationInventory { + pub source_column: String, + pub uses: Vec, + pub classification: EffectiveClassification, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct PropertyClassificationInventory { + pub property: String, + pub source_column: String, + pub semantic_term: String, + pub transform: Option, + pub classification: EffectiveClassification, +} + +pub fn classification_inventory_report( + registry: &CompiledRegistry, + classification_inventory_digest: &str, +) -> Result { + require_inventory_digest(registry, classification_inventory_digest)?; + let mut resources = registry + .resources + .iter() + .map(|resource| { + let mut source_columns = resource + .column_accounting + .iter() + .map(|column| SourceColumnClassificationInventory { + source_column: column.column.clone(), + uses: column.uses.clone(), + classification: column.classification.clone(), + }) + .collect::>(); + source_columns.sort_by(|left, right| left.source_column.cmp(&right.source_column)); + let mut properties = resource + .properties + .iter() + .map(|property| PropertyClassificationInventory { + property: property.name.clone(), + source_column: property.source_column.clone(), + semantic_term: property.semantic_iri.clone(), + transform: property + .transform + .as_ref() + .map(|transform| transform.identifier().to_owned()), + classification: property.classification.clone(), + }) + .collect::>(); + properties.sort_by(|left, right| left.property.cmp(&right.property)); + ResourceClassificationInventory { + resource: resource.id.clone(), + source: resource.source.clone(), + view: resource.view.clone(), + source_columns, + properties, + } + }) + .collect::>(); + resources.sort_by(|left, right| left.resource.cmp(&right.resource)); + Ok(ClassificationInventoryReport { + api_version: "relay.registrystack.org/classification-inventory/v1".into(), + kind: "ClassificationInventory".into(), + registry_identifier: registry.registry_identifier.clone(), + classification_inventory_digest: classification_inventory_digest.into(), + resources, + }) +} + +pub fn render_classification_inventory_report( + report: &ClassificationInventoryReport, +) -> Result, IdentificationError> { + render_canonical(report) +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct RepresentationReport { + pub api_version: String, + pub kind: String, + pub registry_identifier: String, + pub classification_inventory_digest: String, + pub resources: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ResourceRepresentationReport { + pub resource: String, + pub source: String, + pub view: String, + pub operations: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct OperationRepresentationReport { + pub operation: String, + pub operation_kind: String, + pub default_representation: String, + pub representations: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct RepresentationBoundary { + pub representation: String, + pub default: bool, + pub disclosure_profile: String, + pub processed_source_columns: Vec, + pub disclosed_properties: Vec, + pub processing_handling: Handling, + pub disclosure_handling: Handling, + pub transforms: Vec, +} + +pub fn representation_report( + registry: &CompiledRegistry, + classification_inventory_digest: &str, +) -> Result { + require_inventory_digest(registry, classification_inventory_digest)?; + let mut resources = registry + .resources + .iter() + .map(|resource| { + let mut operations = resource + .operations + .iter() + .map(|operation| { + let mut representations = operation + .representations + .iter() + .map(|representation| RepresentationBoundary { + representation: representation.id.clone(), + default: representation.id == operation.default_representation, + disclosure_profile: representation.disclosure_profile.clone(), + processed_source_columns: processed_columns(operation, representation), + disclosed_properties: sorted_unique( + representation.selectable_properties.iter().cloned(), + ), + processing_handling: representation.processing_handling, + disclosure_handling: representation.disclosure_handling, + transforms: sorted_unique( + representation.transform_inventory.iter().cloned(), + ), + }) + .collect::>(); + representations + .sort_by(|left, right| left.representation.cmp(&right.representation)); + OperationRepresentationReport { + operation: operation.identifier.clone(), + operation_kind: operation_kind(&operation.kind), + default_representation: operation.default_representation.clone(), + representations, + } + }) + .collect::>(); + operations.sort_by(|left, right| left.operation.cmp(&right.operation)); + ResourceRepresentationReport { + resource: resource.id.clone(), + source: resource.source.clone(), + view: resource.view.clone(), + operations, + } + }) + .collect::>(); + resources.sort_by(|left, right| left.resource.cmp(&right.resource)); + Ok(RepresentationReport { + api_version: "relay.registrystack.org/representation-report/v1".into(), + kind: "RepresentationReport".into(), + registry_identifier: registry.registry_identifier.clone(), + classification_inventory_digest: classification_inventory_digest.into(), + resources, + }) +} + +pub fn render_representation_report( + report: &RepresentationReport, +) -> Result, IdentificationError> { + render_canonical(report) +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ContextualReviewFindings { + pub api_version: String, + pub kind: String, + pub registry_identifier: String, + pub classification_inventory_digest: String, + pub findings: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ContextualReviewFinding { + pub code: String, + pub status: ContextualFindingStatus, + pub resource: String, + pub operation: Option, + pub representation: Option, + pub properties: Vec, + pub source_columns: Vec, + pub message: String, +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum ContextualFindingStatus { + ReviewRequired, +} + +/// Generate fixed contextual prompts. Findings never grant access, select a +/// representation, or alter a compiled handling floor. +pub fn contextual_review_findings( + registry: &CompiledRegistry, + classification_inventory_digest: &str, +) -> Result { + require_inventory_digest(registry, classification_inventory_digest)?; + let mut findings = Vec::new(); + for resource in ®istry.resources { + let identifying = resource + .properties + .iter() + .filter(|property| is_identifying(&property.classification.privacy)) + .collect::>(); + let sensitive = resource + .properties + .iter() + .filter(|property| { + is_sensitive(&property.classification.privacy) + || property.classification.handling >= Handling::Confidential + }) + .collect::>(); + if identifying + .iter() + .any(|left| sensitive.iter().any(|right| left.name != right.name)) + { + push_finding( + &mut findings, + "classification.context.identifying_and_sensitive", + resource, + None, + None, + identifying + .iter() + .chain(sensitive.iter()) + .map(|property| property.name.clone()), + std::iter::empty(), + "identifying and sensitive properties coexist in one resource", + ); + } + + let linkable = resource + .properties + .iter() + .filter(|property| is_potentially_linkable(&property.classification.privacy)) + .collect::>(); + if linkable.len() > 1 { + push_finding( + &mut findings, + "classification.context.potentially_linkable_combination", + resource, + None, + None, + linkable.iter().map(|property| property.name.clone()), + linkable + .iter() + .map(|property| property.source_column.clone()), + "multiple properties may become linkable in combination", + ); + } + + let personal_public = resource + .properties + .iter() + .filter(|property| { + is_personal(&property.classification.privacy) + && is_public_label(&property.classification.institutional) + }) + .collect::>(); + if !personal_public.is_empty() { + push_finding( + &mut findings, + "classification.context.personal_institutionally_public", + resource, + None, + None, + personal_public + .iter() + .map(|property| property.name.clone()), + personal_public + .iter() + .map(|property| property.source_column.clone()), + "personal properties have public institutional classification and require an explicit publication basis review", + ); + } + + for property in resource + .properties + .iter() + .filter(|property| property.transform.is_some()) + { + let source = resource + .column_accounting + .iter() + .find(|column| column.column == property.source_column); + if source.is_some_and(|column| { + column.classification.handling > property.classification.handling + }) { + push_finding( + &mut findings, + "classification.context.transform_weaker_than_source", + resource, + None, + None, + [property.name.clone()], + [property.source_column.clone()], + "a transformed property has weaker handling than its source column", + ); + } + } + + let mut properties_by_column: BTreeMap<&str, Vec<_>> = BTreeMap::new(); + for property in &resource.properties { + properties_by_column + .entry(&property.source_column) + .or_default() + .push(property); + } + for (column, properties) in properties_by_column { + let incompatible = properties.iter().enumerate().any(|(index, left)| { + properties.iter().skip(index + 1).any(|right| { + left.classification.privacy != right.classification.privacy + || left.classification.institutional != right.classification.institutional + || left.classification.handling != right.classification.handling + }) + }); + if incompatible { + push_finding( + &mut findings, + "classification.context.source_column_incompatible_properties", + resource, + None, + None, + properties.iter().map(|property| property.name.clone()), + [column.to_owned()], + "one source column backs properties with incompatible classifications", + ); + } + } + + for operation in &resource.operations { + for representation in &operation.representations { + let restrictive_selectors = operation + .query + .selectors + .iter() + .filter(|selector| { + column_handling(resource, &selector.source_column) + .is_some_and(|handling| handling > representation.disclosure_handling) + }) + .collect::>(); + if !restrictive_selectors.is_empty() { + push_finding( + &mut findings, + "classification.context.selector_more_restrictive_than_disclosure", + resource, + Some(&operation.identifier), + Some(&representation.id), + representation.selectable_properties.iter().cloned(), + restrictive_selectors + .iter() + .map(|selector| selector.source_column.clone()), + "one or more selectors are more restrictive than disclosed properties", + ); + } + if matches!(operation.kind, OperationKind::List) + && representation.disclosure_handling >= Handling::Confidential + { + push_finding( + &mut findings, + "classification.context.nonpublic_list_disclosure", + resource, + Some(&operation.identifier), + Some(&representation.id), + representation.selectable_properties.iter().cloned(), + std::iter::empty(), + "confidential or restricted data appears in a list representation", + ); + } + if matches!(representation.access, CompiledAccess::Public) { + let disclosed_columns = disclosed_source_columns(resource, representation); + let hidden_nonpublic = processed_columns(operation, representation) + .into_iter() + .filter(|column| !disclosed_columns.contains(column)) + .filter(|column| { + column_handling(resource, column) + .is_some_and(|handling| handling > Handling::Public) + }) + .collect::>(); + if !hidden_nonpublic.is_empty() { + push_finding( + &mut findings, + "classification.context.public_processes_hidden_nonpublic", + resource, + Some(&operation.identifier), + Some(&representation.id), + representation.selectable_properties.iter().cloned(), + hidden_nonpublic, + "a public representation processes hidden non-public source columns", + ); + } + } + } + } + } + findings.sort_by(|left, right| { + left.resource + .cmp(&right.resource) + .then(left.operation.cmp(&right.operation)) + .then(left.representation.cmp(&right.representation)) + .then(left.code.cmp(&right.code)) + .then(left.properties.cmp(&right.properties)) + .then(left.source_columns.cmp(&right.source_columns)) + }); + Ok(ContextualReviewFindings { + api_version: "relay.registrystack.org/contextual-review-findings/v1".into(), + kind: "ContextualReviewFindings".into(), + registry_identifier: registry.registry_identifier.clone(), + classification_inventory_digest: classification_inventory_digest.into(), + findings, + }) +} + +pub fn render_contextual_review_findings( + report: &ContextualReviewFindings, +) -> Result, IdentificationError> { + render_canonical(report) +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ClassificationReviewExpectation { + pub registry_identifier: String, + pub classification_inventory_digest: String, + /// Recomputed from the current contract, observation, and embedded pack. + pub generated_identification: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ReviewValidation { + pub diagnostics: Vec, +} + +impl ReviewValidation { + pub fn is_valid(&self) -> bool { + self.diagnostics.is_empty() + } +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ReviewDiagnostic { + pub code: String, + pub location: String, + pub message: String, +} + +/// Build an explicitly unreviewed starter. It is deterministic and cannot pass +/// [`validate_classification_review`] until a reviewer supplies a real date, +/// rationale, and reviewed status. +pub fn classification_review_starter( + contract: &RegistryContract, + classification_inventory_digest: &str, + report: &IdentificationReport, +) -> Result { + Ok(ClassificationReviewDocument { + api_version: REVIEW_API_VERSION.into(), + kind: REVIEW_KIND.into(), + registry_identifier: contract.registry.registry_identifier.clone(), + classification_inventory_digest: classification_inventory_digest.into(), + method: IdentificationMethod::Generated, + reviewer: contract.registry.authority.identifier.clone(), + review_date: "pending-review".into(), + status: ReviewStatus::Suggested, + rationale_ref: "pending-review".into(), + generated_identification: Some(GeneratedIdentificationBinding { + report_ref: REVIEWED_IDENTIFICATION_REPORT_PATH.into(), + report_digest: identification_report_digest(report)?, + rule_pack: report.rule_pack.clone(), + }), + }) +} + +pub fn render_classification_review_yaml( + review: &ClassificationReviewDocument, +) -> Result, IdentificationError> { + serde_norway::to_string(review) + .map(String::into_bytes) + .map_err(|_| IdentificationError::ReviewRender) +} + +pub fn parse_classification_review_yaml( + bytes: &[u8], +) -> Result { + if bytes.len() > 64 * 1024 { + return Err(IdentificationError::ReviewParse); + } + serde_norway::from_slice(bytes).map_err(|_| IdentificationError::ReviewParse) +} + +/// Validate freshness and method-specific review evidence without trusting an +/// authored report digest. The caller supplies the independently recomputed +/// expected inventory and, for generated reviews, report and pack binding. +pub fn validate_classification_review( + review: &ClassificationReviewDocument, + expected: &ClassificationReviewExpectation, +) -> ReviewValidation { + let mut diagnostics = Vec::new(); + if review.api_version != REVIEW_API_VERSION || review.kind != REVIEW_KIND { + push_review_diagnostic( + &mut diagnostics, + "classification.review_identity_invalid", + "apiVersion", + "the classification review document identity is unsupported", + ); + } + if review.registry_identifier != expected.registry_identifier { + push_review_diagnostic( + &mut diagnostics, + "classification.review_registry_stale", + "registryIdentifier", + "the classification review is bound to another Registry", + ); + } + if !valid_sha256(&review.classification_inventory_digest) + || review.classification_inventory_digest != expected.classification_inventory_digest + { + push_review_diagnostic( + &mut diagnostics, + "classification.review_inventory_stale", + "classificationInventoryDigest", + "the classification review does not bind the current inventory", + ); + } + if review.status != ReviewStatus::Reviewed { + push_review_diagnostic( + &mut diagnostics, + "classification.review_unreviewed", + "status", + "production classification requires reviewed institutional evidence", + ); + } + if !valid_review_text(&review.reviewer) { + push_review_diagnostic( + &mut diagnostics, + "classification.review_reviewer_invalid", + "reviewer", + "the reviewer or reviewing authority identifier is invalid", + ); + } + if !canonical_review_date(&review.review_date) { + push_review_diagnostic( + &mut diagnostics, + "classification.review_date_invalid", + "reviewDate", + "the review date must be a canonical calendar date", + ); + } + if !valid_relative_reference(&review.rationale_ref) { + push_review_diagnostic( + &mut diagnostics, + "classification.review_rationale_invalid", + "rationaleRef", + "the rationale reference must be a bounded contained relative reference", + ); + } + + match review.method { + IdentificationMethod::Generated => { + let Some(actual) = review.generated_identification.as_ref() else { + push_review_diagnostic( + &mut diagnostics, + "classification.review_generated_binding_missing", + "generatedIdentification", + "a generated review must bind the recomputed report and rule pack", + ); + return ReviewValidation { diagnostics }; + }; + if !valid_relative_reference(&actual.report_ref) + || !valid_sha256(&actual.report_digest) + || !valid_sha256(&actual.rule_pack.digest) + { + push_review_diagnostic( + &mut diagnostics, + "classification.review_generated_binding_invalid", + "generatedIdentification", + "the generated identification binding is invalid", + ); + } + match expected.generated_identification.as_ref() { + Some(current) if actual != current => push_review_diagnostic( + &mut diagnostics, + "classification.review_identification_stale", + "generatedIdentification", + "the classification review does not bind the current identification report and rule pack", + ), + None => push_review_diagnostic( + &mut diagnostics, + "classification.review_identification_unverified", + "generatedIdentification", + "the generated identification binding was not independently recomputed", + ), + Some(_) => {} + } + } + IdentificationMethod::Imported | IdentificationMethod::Manual => { + if review.generated_identification.is_some() { + push_review_diagnostic( + &mut diagnostics, + "classification.review_generated_binding_forbidden", + "generatedIdentification", + "manual and imported reviews do not carry generated-identification evidence", + ); + } + } + } + + ReviewValidation { diagnostics } +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct RulePack { + pack_id: String, + pack_version: String, + privacy_candidate_vocabulary: CandidateVocabulary, + rules: Vec, +} + +impl RulePack { + fn reference(&self, digest: &str) -> RulePackBinding { + RulePackBinding { + id: self.pack_id.clone(), + version: self.pack_version.clone(), + digest: digest.into(), + } + } +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct Rule { + id: String, + version: String, + family: RuleFamily, + confidence: RuleConfidence, + when: Vec, + suggestion: RuleSuggestion, +} + +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "lowercase")] +enum RuleConfidence { + Weak, + Strong, + Exact, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "kebab-case")] +enum RuleCondition { + Any, + AuthoredRole { value: AuthoredRole }, + CodelistPresent, + DeclaredType { values: Vec }, + NameEquals { values: Vec }, + NameSuffix { values: Vec }, + NameTokenAny { values: Vec }, + PrimaryKey, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct RuleSuggestion { + role: TechnicalRole, + privacy: Vec, +} + +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "kebab-case")] +enum AuthoredRole { + Codelist, + Filter, + LifecycleState, + Order, + Property, + RecordedAt, + RecordIdentifier, + RevisionIdentifier, + RowBinding, + Selector, +} + +#[derive(Clone, Debug, Default)] +struct ColumnHints { + roles: BTreeSet, + properties: BTreeSet<(String, String)>, + codelist: bool, +} + +fn load_core_pack(bytes: &[u8], expected_digest: &str) -> Result { + if bytes.len() > MAXIMUM_PACK_BYTES || sha256(bytes) != expected_digest { + return Err(IdentificationError::PackDigestMismatch); + } + let value = parse_json_strict(bytes).map_err(|_| IdentificationError::PackInvalid)?; + let pack: RulePack = + serde_json::from_value(value).map_err(|_| IdentificationError::PackInvalid)?; + validate_pack(&pack)?; + Ok(pack) +} + +fn validate_pack(pack: &RulePack) -> Result<(), IdentificationError> { + if pack.pack_id != "registrystack.relay.identification.core" + || pack.pack_version != "1" + || pack.privacy_candidate_vocabulary.scheme != "urn:registrystack:relay:privacy-candidate" + || pack.privacy_candidate_vocabulary.version != "1" + || pack.rules.is_empty() + || pack.rules.len() > MAXIMUM_RULES + { + return Err(IdentificationError::PackInvalid); + } + let mut rule_ids = BTreeSet::new(); + let mut fallback_count = 0; + for rule in &pack.rules { + if !valid_pack_identifier(&rule.id) + || !valid_pack_identifier(&rule.version) + || rule.when.is_empty() + || rule.when.len() > MAXIMUM_CONDITIONS_PER_RULE + || !rule_ids.insert(rule.id.as_str()) + || rule.suggestion.privacy.len() > 8 + || rule + .suggestion + .privacy + .iter() + .any(|value| !valid_pack_identifier(value)) + || rule + .when + .iter() + .any(|condition| !valid_condition(condition)) + { + return Err(IdentificationError::PackInvalid); + } + if rule.id == "core.column.fallback" { + fallback_count += 1; + if rule.when != [RuleCondition::Any] + || rule.suggestion.role != TechnicalRole::Property + || rule.confidence != RuleConfidence::Weak + { + return Err(IdentificationError::PackInvalid); + } + } else if rule.when.contains(&RuleCondition::Any) { + return Err(IdentificationError::PackInvalid); + } + } + if fallback_count != 1 { + return Err(IdentificationError::PackInvalid); + } + Ok(()) +} + +fn valid_condition(condition: &RuleCondition) -> bool { + let values = match condition { + RuleCondition::DeclaredType { values } + | RuleCondition::NameEquals { values } + | RuleCondition::NameSuffix { values } + | RuleCondition::NameTokenAny { values } => Some(values), + RuleCondition::Any + | RuleCondition::AuthoredRole { .. } + | RuleCondition::CodelistPresent + | RuleCondition::PrimaryKey => None, + }; + values.is_none_or(|values| { + !values.is_empty() + && values.len() <= 32 + && values.iter().all(|value| { + !value.is_empty() + && value.len() <= 64 + && value.bytes().all(|byte| { + byte.is_ascii_lowercase() + || byte.is_ascii_digit() + || matches!(byte, b'_' | b'-') + }) + }) + }) +} + +fn valid_pack_identifier(value: &str) -> bool { + !value.is_empty() + && value.len() <= 128 + && value.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'-' | b'_') + }) +} + +fn ensure_observation_bounds(observed: &[ObservedSourceSchema]) -> Result<(), IdentificationError> { + let views = observed + .iter() + .map(|schema| schema.views.len()) + .sum::(); + let columns = observed + .iter() + .flat_map(|schema| &schema.views) + .map(|view| view.columns.len()) + .sum::(); + if observed.len() > MAXIMUM_SOURCES || views > MAXIMUM_VIEWS || columns > MAXIMUM_COLUMNS { + Err(IdentificationError::InputTooLarge) + } else { + Ok(()) + } +} + +fn normalized_observation(observed: &[ObservedSourceSchema]) -> Vec { + let mut normalized = observed.to_vec(); + for schema in &mut normalized { + for view in &mut schema.views { + view.columns + .sort_by(|left, right| left.name.cmp(&right.name)); + } + schema + .views + .sort_by(|left, right| left.name.cmp(&right.name)); + } + normalized.sort_by(|left, right| left.source.cmp(&right.source)); + normalized +} + +fn authored_hints(contract: &RegistryContract) -> BTreeMap<(String, String, String), ColumnHints> { + let mut hints = BTreeMap::new(); + for resource in &contract.resources { + let source = resource.source.source.as_str(); + let view = resource.source.view.as_str(); + add_role( + &mut hints, + source, + view, + &resource.record_context.record_identifier.source_column, + AuthoredRole::RecordIdentifier, + ); + add_role( + &mut hints, + source, + view, + &resource.record_context.revision_identifier.source_column, + AuthoredRole::RevisionIdentifier, + ); + add_role( + &mut hints, + source, + view, + &resource.record_context.lifecycle_state.source_column, + AuthoredRole::LifecycleState, + ); + add_codelist( + &mut hints, + source, + view, + &resource.record_context.lifecycle_state.source_column, + ); + add_role( + &mut hints, + source, + view, + &resource.record_context.recorded_at.source_column, + AuthoredRole::RecordedAt, + ); + + for (property_name, property) in resource.properties.iter() { + let entry = column_hint(&mut hints, source, view, &property.source_column); + entry.roles.insert(AuthoredRole::Property); + entry + .properties + .insert((property_name.into(), property.semantic_term.clone())); + if property.codelist.is_some() { + entry.roles.insert(AuthoredRole::Codelist); + entry.codelist = true; + } + } + + if let Some(operation) = &resource.operations.list { + for filter in &operation.filters { + if let Some(property) = resource.properties.get(&filter.property) { + add_role( + &mut hints, + source, + view, + &property.source_column, + AuthoredRole::Filter, + ); + } + } + for property_name in &operation.order_by { + if let Some(property) = resource.properties.get(property_name) { + add_role( + &mut hints, + source, + view, + &property.source_column, + AuthoredRole::Order, + ); + } + } + for (_, representation) in operation.representations.iter() { + add_access_roles(&mut hints, source, view, &representation.access); + } + } + if let Some(operation) = &resource.operations.read { + for (_, representation) in operation.representations.iter() { + add_access_roles(&mut hints, source, view, &representation.access); + } + } + for lookup in &resource.operations.lookups { + for (_, selector) in lookup.request_body.selectors.iter() { + add_role( + &mut hints, + source, + view, + &selector.source_column, + AuthoredRole::Selector, + ); + if selector.codelist.is_some() { + add_codelist(&mut hints, source, view, &selector.source_column); + } + } + for (_, representation) in lookup.representations.iter() { + add_access_roles(&mut hints, source, view, &representation.access); + } + } + } + hints +} + +fn add_access_roles( + hints: &mut BTreeMap<(String, String, String), ColumnHints>, + source: &str, + view: &str, + access: &AccessRule, +) { + let AccessRule::Protected(protected) = access else { + return; + }; + let source_column = match protected.authority_row_binding.as_ref() { + Some(AuthorityRowBinding::Claim(binding)) => Some(binding.source_column.as_str()), + Some(AuthorityRowBinding::Principal(binding)) => Some(binding.source_column.as_str()), + None => None, + }; + if let Some(source_column) = source_column { + add_role(hints, source, view, source_column, AuthoredRole::RowBinding); + } +} + +fn add_role( + hints: &mut BTreeMap<(String, String, String), ColumnHints>, + source: &str, + view: &str, + column: &str, + role: AuthoredRole, +) { + column_hint(hints, source, view, column).roles.insert(role); +} + +fn add_codelist( + hints: &mut BTreeMap<(String, String, String), ColumnHints>, + source: &str, + view: &str, + column: &str, +) { + let hint = column_hint(hints, source, view, column); + hint.roles.insert(AuthoredRole::Codelist); + hint.codelist = true; +} + +fn column_hint<'a>( + hints: &'a mut BTreeMap<(String, String, String), ColumnHints>, + source: &str, + view: &str, + column: &str, +) -> &'a mut ColumnHints { + hints + .entry((source.into(), view.into(), column.into())) + .or_default() +} + +fn identify_column( + pack: &RulePack, + rule_pack: &RulePackBinding, + source: &str, + view: &str, + column: &ObservedColumn, + hint: Option<&ColumnHints>, +) -> IdentificationCandidate { + let normalized_name = normalize_column_name(&column.name); + let tokens = normalized_name + .split('_') + .filter(|token| !token.is_empty()) + .collect::>(); + let default_hint = ColumnHints::default(); + let hint = hint.unwrap_or(&default_hint); + let mut matches = pack + .rules + .iter() + .filter(|rule| { + rule.when.iter().all(|condition| { + condition_matches( + condition, + &normalized_name, + &tokens, + &column.declared_type, + column.primary_key, + hint, + ) + }) + }) + .collect::>(); + matches.sort_by(|left, right| left.id.cmp(&right.id)); + + let property_conflict = hint.properties.len() > 1; + let (suggested_property, suggested_semantic_term) = if property_conflict { + (None, None) + } else if let Some((property, semantic_term)) = hint.properties.iter().next() { + (Some(property.clone()), Some(semantic_term.clone())) + } else { + let property = property_name(&normalized_name); + (Some(property.clone()), Some(format!("local:{property}"))) + }; + + // The generic rule is an actual fallback, not corroborating evidence. It + // must disappear from the candidate as soon as any authored or schema + // rule matches, including a weak but more specific rule. + if matches.iter().any(|rule| rule.id != "core.column.fallback") { + matches.retain(|rule| rule.id != "core.column.fallback"); + } + let considered = &matches; + let maximum_confidence = considered + .iter() + .map(|rule| rule.confidence) + .max() + .unwrap_or(RuleConfidence::Weak); + let top = considered + .iter() + .copied() + .filter(|rule| rule.confidence == maximum_confidence) + .collect::>(); + let has_specific_role = top.iter().any(|rule| { + !matches!( + rule.suggestion.role, + TechnicalRole::Codelist | TechnicalRole::Identifier | TechnicalRole::Property + ) + }); + let top_roles = top + .iter() + .filter_map(|rule| { + let role = rule.suggestion.role; + (!has_specific_role + || !matches!( + role, + TechnicalRole::Codelist | TechnicalRole::Identifier | TechnicalRole::Property + )) + .then_some(role) + }) + .collect::>(); + let role_conflict = top_roles.len() > 1; + let conflict = property_conflict || role_conflict; + let suggested_role = if conflict { + None + } else { + top_roles + .iter() + .next() + .copied() + .or(Some(TechnicalRole::Property)) + }; + let suggested_privacy = matches + .iter() + .flat_map(|rule| rule.suggestion.privacy.iter()) + .map(|term| CandidateTerm { + scheme: pack.privacy_candidate_vocabulary.scheme.clone(), + version: pack.privacy_candidate_vocabulary.version.clone(), + term: term.clone(), + }) + .collect::>() + .into_iter() + .collect(); + let matched_rules = matches + .iter() + .map(|rule| MatchedRule { + id: rule.id.clone(), + version: rule.version.clone(), + family: rule.family, + }) + .collect(); + let confidence = if conflict { + CategoricalConfidence::Conflict + } else { + match maximum_confidence { + RuleConfidence::Exact => CategoricalConfidence::Exact, + RuleConfidence::Strong => CategoricalConfidence::Strong, + RuleConfidence::Weak => CategoricalConfidence::Weak, + } + }; + + IdentificationCandidate { + source: source.into(), + view: view.into(), + source_column: column.name.clone(), + suggested_property, + suggested_semantic_term, + suggested_role, + suggested_privacy, + matched_rules, + rule_pack: rule_pack.clone(), + confidence, + status: if conflict { + IdentificationStatus::Uncertain + } else { + IdentificationStatus::Suggested + }, + } +} + +fn condition_matches( + condition: &RuleCondition, + normalized_name: &str, + tokens: &BTreeSet<&str>, + declared_type: &str, + primary_key: bool, + hint: &ColumnHints, +) -> bool { + match condition { + RuleCondition::Any => true, + RuleCondition::AuthoredRole { value } => hint.roles.contains(value), + RuleCondition::CodelistPresent => hint.codelist, + RuleCondition::DeclaredType { values } => { + let normalized_type = declared_type.trim().to_ascii_lowercase(); + values.iter().any(|value| value == &normalized_type) + } + RuleCondition::NameEquals { values } => values.iter().any(|value| value == normalized_name), + RuleCondition::NameSuffix { values } => { + values.iter().any(|value| normalized_name.ends_with(value)) + } + RuleCondition::NameTokenAny { values } => { + values.iter().any(|value| tokens.contains(value.as_str())) + } + RuleCondition::PrimaryKey => primary_key, + } +} + +fn normalize_column_name(value: &str) -> String { + let mut normalized = String::with_capacity(value.len()); + let mut previous_was_lower_or_digit = false; + let mut previous_was_separator = true; + for character in value.chars() { + if character.is_ascii_alphanumeric() { + if character.is_ascii_uppercase() + && previous_was_lower_or_digit + && !previous_was_separator + { + normalized.push('_'); + } + normalized.push(character.to_ascii_lowercase()); + previous_was_lower_or_digit = + character.is_ascii_lowercase() || character.is_ascii_digit(); + previous_was_separator = false; + } else if !previous_was_separator && !normalized.is_empty() { + normalized.push('_'); + previous_was_lower_or_digit = false; + previous_was_separator = true; + } + } + while normalized.ends_with('_') { + normalized.pop(); + } + if normalized.is_empty() { + "column".into() + } else { + normalized + } +} + +fn property_name(normalized: &str) -> String { + let mut tokens = normalized.split('_').filter(|token| !token.is_empty()); + let mut property = tokens.next().unwrap_or("column").to_owned(); + for token in tokens { + let mut characters = token.chars(); + if let Some(first) = characters.next() { + property.push(first.to_ascii_uppercase()); + property.extend(characters); + } + } + property +} + +fn render_canonical(value: &T) -> Result, IdentificationError> { + let value = serde_json::to_value(value).map_err(|_| IdentificationError::Canonicalization)?; + canonicalize_json(&value).map_err(|_| IdentificationError::Canonicalization) +} + +fn require_inventory_digest( + registry: &CompiledRegistry, + value: &str, +) -> Result<(), IdentificationError> { + let current = crate::compiler::classification_inventory_digest(registry) + .map_err(|_| IdentificationError::Canonicalization)?; + if valid_sha256(value) && value == current { + Ok(()) + } else { + Err(IdentificationError::InventoryDigestInvalid) + } +} + +fn operation_kind(kind: &OperationKind) -> String { + match kind { + OperationKind::List => "list".into(), + OperationKind::Read => "read".into(), + OperationKind::Lookup { name } => format!("lookup:{name}"), + } +} + +fn processed_columns( + operation: &CompiledOperation, + representation: &CompiledRepresentation, +) -> Vec { + let mut columns = representation + .projected_columns + .iter() + .cloned() + .collect::>(); + columns.extend( + operation + .query + .filters + .iter() + .map(|filter| filter.source_column.clone()), + ); + columns.extend(operation.query.order_by.iter().cloned()); + columns.extend( + operation + .query + .selectors + .iter() + .map(|selector| selector.source_column.clone()), + ); + if let CompiledAccess::Protected { + row_binding: Some(binding), + .. + } = &representation.access + { + columns.insert(binding.source_column.clone()); + } + columns.into_iter().collect() +} + +fn disclosed_source_columns( + resource: &CompiledResource, + representation: &CompiledRepresentation, +) -> BTreeSet { + let mut columns = [ + &resource.record_context.record_identifier_column, + &resource.record_context.revision_identifier_column, + &resource.record_context.lifecycle_state_column, + &resource.record_context.recorded_at_column, + ] + .into_iter() + .cloned() + .collect::>(); + for name in &representation.selectable_properties { + if let Some(property) = resource + .properties + .iter() + .find(|property| property.name == *name && property.transform.is_none()) + { + columns.insert(property.source_column.clone()); + } + } + columns +} + +fn column_handling(resource: &CompiledResource, column: &str) -> Option { + resource + .column_accounting + .iter() + .find(|account| account.column == column) + .map(|account| account.classification.handling) +} + +fn sorted_unique(values: impl Iterator) -> Vec { + values.collect::>().into_iter().collect() +} + +#[allow(clippy::too_many_arguments)] +fn push_finding( + findings: &mut Vec, + code: &str, + resource: &CompiledResource, + operation: Option<&str>, + representation: Option<&str>, + properties: I, + source_columns: J, + message: &str, +) where + I: IntoIterator, + J: IntoIterator, +{ + findings.push(ContextualReviewFinding { + code: code.into(), + status: ContextualFindingStatus::ReviewRequired, + resource: resource.id.clone(), + operation: operation.map(str::to_owned), + representation: representation.map(str::to_owned), + properties: sorted_unique(properties.into_iter()), + source_columns: sorted_unique(source_columns.into_iter()), + message: message.into(), + }); +} + +fn classification_tokens(value: &str) -> BTreeSet { + value + .split(|character: char| !character.is_ascii_alphanumeric()) + .filter(|token| !token.is_empty()) + .map(str::to_ascii_lowercase) + .collect() +} + +fn is_identifying(value: &str) -> bool { + classification_tokens(value) + .iter() + .any(|token| token.starts_with("identif")) +} + +fn is_sensitive(value: &str) -> bool { + classification_tokens(value) + .iter() + .any(|token| token.starts_with("sensitive")) +} + +fn is_personal(value: &str) -> bool { + classification_tokens(value).iter().any(|token| { + token.starts_with("personal") || token.starts_with("identif") || token == "contact" + }) +} + +fn is_potentially_linkable(value: &str) -> bool { + classification_tokens(value).iter().any(|token| { + token.starts_with("quasi") + || token.starts_with("link") + || token.starts_with("indirect") + || token.starts_with("potential") + }) +} + +fn is_public_label(value: &str) -> bool { + classification_tokens(value).contains("public") +} + +fn digest_serializable(value: &T) -> Result { + let value = serde_json::to_value(value).map_err(|_| IdentificationError::Canonicalization)?; + let bytes = canonicalize_json(&value).map_err(|_| IdentificationError::Canonicalization)?; + Ok(sha256(&bytes)) +} + +fn sha256(bytes: &[u8]) -> String { + format!("sha256:{}", hex::encode(Sha256::digest(bytes))) +} + +fn valid_sha256(value: &str) -> bool { + value.strip_prefix("sha256:").is_some_and(|digest| { + digest.len() == 64 + && digest + .bytes() + .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) + }) +} + +fn canonical_review_date(value: &str) -> bool { + NaiveDate::parse_from_str(value, "%Y-%m-%d") + .ok() + .is_some_and(|date| date.format("%Y-%m-%d").to_string() == value) +} + +fn valid_review_text(value: &str) -> bool { + !value.trim().is_empty() + && value.len() <= MAXIMUM_REVIEW_TEXT_BYTES + && !value.chars().any(char::is_control) +} + +fn valid_relative_reference(value: &str) -> bool { + valid_review_text(value) + && !Path::new(value).is_absolute() + && Path::new(value) + .components() + .all(|component| matches!(component, Component::Normal(_))) +} + +fn push_review_diagnostic( + diagnostics: &mut Vec, + code: &str, + location: &str, + message: &str, +) { + diagnostics.push(ReviewDiagnostic { + code: code.into(), + location: location.into(), + message: message.into(), + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pack_tampering_is_refused_before_parsing() { + let mut tampered = CORE_PACK_BYTES.to_vec(); + tampered[0] ^= 1; + assert_eq!( + load_core_pack(&tampered, CORE_PACK_DIGEST), + Err(IdentificationError::PackDigestMismatch) + ); + } + + #[test] + fn a_mismatched_pack_pin_is_refused() { + assert_eq!( + load_core_pack(CORE_PACK_BYTES, &format!("sha256:{}", "0".repeat(64))), + Err(IdentificationError::PackDigestMismatch) + ); + } +} diff --git a/crates/registry-relay-v2/src/lib.rs b/crates/registry-relay-v2/src/lib.rs index b97aa8442..9deb8205d 100644 --- a/crates/registry-relay-v2/src/lib.rs +++ b/crates/registry-relay-v2/src/lib.rs @@ -11,6 +11,7 @@ pub mod cursor; pub mod diff; #[cfg(feature = "tooling")] pub mod fixtures; +pub mod identification; pub mod model; pub mod package; pub mod problem; @@ -21,7 +22,8 @@ pub mod sqlite_runtime; pub mod startup; #[cfg(feature = "tooling")] pub mod tooling; +pub mod transform; -pub use compiler::{compile, CompileError}; +pub use compiler::{classification_inventory_digest, compile, CompileError}; pub use contract::{RegistryContract, RelayRuntime}; pub use model::{CompileProfile, CompiledRegistry, ObservedSourceSchema}; diff --git a/crates/registry-relay-v2/src/model.rs b/crates/registry-relay-v2/src/model.rs index 6ce456103..7705103a8 100644 --- a/crates/registry-relay-v2/src/model.rs +++ b/crates/registry-relay-v2/src/model.rs @@ -4,8 +4,8 @@ use serde::{Deserialize, Serialize}; use crate::contract::{ - AlignmentTarget, DataType, Handling, ProcessingDescription, SemanticAlignment, SourceProfile, - Visibility, + AlignmentTarget, DataType, DateInputType, DatePrecision, Handling, IdentificationMethod, + PartialStringReveal, ProcessingDescription, SemanticAlignment, SourceProfile, Visibility, }; #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] @@ -91,6 +91,7 @@ pub struct CompiledRegistry { pub local_vocabulary: String, pub semantic_alignments: Vec, pub governed_files: Vec, + pub classification_review: Option, pub codelists: Vec, pub sources: Vec, pub resources: Vec, @@ -159,6 +160,7 @@ pub struct CompiledProperty { pub label: String, pub description: String, pub source_column: String, + pub transform: Option, pub data_type: DataType, pub codelist: Option, pub source_required: bool, @@ -166,6 +168,32 @@ pub struct CompiledProperty { pub classification: EffectiveClassification, } +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "kebab-case")] +pub enum CompiledTransform { + PartialString { + identifier: String, + reveal: PartialStringReveal, + characters: u16, + }, + DatePrecision { + identifier: String, + source_type: DateInputType, + precision: DatePrecision, + }, +} + +impl CompiledTransform { + #[must_use] + pub fn identifier(&self) -> &str { + match self { + Self::PartialString { identifier, .. } | Self::DatePrecision { identifier, .. } => { + identifier + } + } + } +} + #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct EffectiveClassification { @@ -191,6 +219,29 @@ pub struct CompiledDisclosureProfile { pub maximum_handling: Handling, } +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CompiledClassificationReview { + pub registry_identifier: String, + pub classification_inventory_digest: String, + pub method: IdentificationMethod, + pub reviewer: String, + pub review_date: String, + pub status: crate::contract::ReviewStatus, + pub rationale_ref: String, + pub generated_identification: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CompiledGeneratedIdentificationBinding { + pub report_ref: String, + pub report_digest: String, + pub rule_pack_id: String, + pub rule_pack_version: String, + pub rule_pack_digest: String, +} + #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct CompiledOperation { @@ -198,11 +249,22 @@ pub struct CompiledOperation { pub family: CapabilityFamily, pub pattern: ConsultationPattern, pub kind: OperationKind, + pub default_representation: String, + pub representations: Vec, + pub query: QueryPlan, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CompiledRepresentation { + pub id: String, pub access: CompiledAccess, pub disclosure_profile: String, pub selectable_properties: Vec, - pub query: QueryPlan, - pub maximum_handling: Handling, + pub projected_columns: Vec, + pub processing_handling: Handling, + pub disclosure_handling: Handling, + pub transform_inventory: Vec, pub schema_reference: String, pub semantic_model_reference: String, pub context_reference: String, @@ -267,7 +329,6 @@ pub enum RowAuthoritySource { pub struct QueryPlan { pub source: String, pub view: String, - pub projected_columns: Vec, pub filters: Vec, pub selectors: Vec, pub order_by: Vec, diff --git a/crates/registry-relay-v2/src/package.rs b/crates/registry-relay-v2/src/package.rs index 7a47764b2..1d76a04ec 100644 --- a/crates/registry-relay-v2/src/package.rs +++ b/crates/registry-relay-v2/src/package.rs @@ -13,7 +13,9 @@ use thiserror::Error; use crate::artifacts::{generate_artifacts, ArtifactSet, GeneratedArtifact}; use crate::compiler::{compile_contract_with_governed_files, GovernedFileSet}; use crate::contract::{RegistryContract, Visibility}; -use crate::model::{CompileProfile, CompiledRegistry, ObservedSourceSchema}; +use crate::model::{ + CompileProfile, CompiledClassificationReview, CompiledRegistry, ObservedSourceSchema, +}; const PACKAGE_VERSION: &str = "relay.registrystack.org/package/v1alpha1"; const MAX_AUTHORED_FILES: usize = 256; @@ -42,6 +44,7 @@ pub struct PackageArtifact { pub media_type: String, pub visibility: Visibility, pub operation_identifier: Option, + pub representation_identifier: Option, pub sha256: String, } @@ -94,7 +97,11 @@ pub fn build_package( if output_dir.exists() { return Err(PackageError::DestinationExists); } - let authored = capture_governed_closure(project_root, contract)?; + let authored = capture_governed_closure( + project_root, + contract, + compiled.classification_review.as_ref(), + )?; let mut files = Vec::new(); let registry_bytes = read_regular(&project_root.join("registry.yaml"))?; files.push(file_entry( @@ -133,6 +140,7 @@ pub fn build_package( media_type: artifact.media_type.clone(), visibility: artifact.visibility, operation_identifier: artifact.operation_identifier.clone(), + representation_identifier: artifact.representation_identifier.clone(), sha256: artifact.sha256.clone(), }) .collect::>(); @@ -366,6 +374,7 @@ pub fn load_package(package_path: &Path) -> Result>(); @@ -406,10 +415,17 @@ struct UnsignedManifest<'a> { fn capture_governed_closure( project_root: &Path, contract: &RegistryContract, + review: Option<&CompiledClassificationReview>, ) -> Result>, PackageError> { let mut references = BTreeSet::new(); references.insert(contract.registry.identifier_lifecycle_policy_ref.as_str()); references.insert(contract.classifications.provenance_ref.as_str()); + if let Some(review) = review { + references.insert(review.rationale_ref.as_str()); + if let Some(generated) = &review.generated_identification { + references.insert(generated.report_ref.as_str()); + } + } for alignment in &contract.semantics.alignments { references.insert(alignment.profile_ref.as_str()); } @@ -741,7 +757,7 @@ mod tests { .expect("strict contract"); assert!(matches!( - capture_governed_closure(&project, &contract), + capture_governed_closure(&project, &contract, None), Err(PackageError::UnsafeClosure) )); } diff --git a/crates/registry-relay-v2/src/problem.rs b/crates/registry-relay-v2/src/problem.rs index 9f0bbf27a..29129059e 100644 --- a/crates/registry-relay-v2/src/problem.rs +++ b/crates/registry-relay-v2/src/problem.rs @@ -21,6 +21,8 @@ pub enum ProblemCode { UnknownFilter, InvalidFilter, CursorInvalid, + RepresentationInvalid, + RepresentationNotFound, MissingCredential, InvalidCredential, ConsultationDenied, @@ -47,6 +49,8 @@ impl ProblemCode { Self::UnknownFilter => "filter.unknown_field", Self::InvalidFilter => "filter.invalid_value", Self::CursorInvalid => "query.cursor_invalid", + Self::RepresentationInvalid => "request.representation_invalid", + Self::RepresentationNotFound => "representation.not_found", Self::MissingCredential => "auth.missing_credential", Self::InvalidCredential => "auth.invalid_credential", Self::ConsultationDenied => "consultation.denied", @@ -73,6 +77,8 @@ impl ProblemCode { Self::UnknownFilter => "Filter is not declared", Self::InvalidFilter => "Filter value is invalid", Self::CursorInvalid => "Cursor is invalid", + Self::RepresentationInvalid => "Representation selection is invalid", + Self::RepresentationNotFound => "Requested representation was not found", Self::MissingCredential => "Bearer access token is required", Self::InvalidCredential => "Bearer access token is invalid", Self::ConsultationDenied => "Consultation is not permitted", @@ -98,10 +104,13 @@ impl ProblemCode { | Self::FieldsInvalid | Self::UnknownFilter | Self::InvalidFilter - | Self::CursorInvalid => 400, + | Self::CursorInvalid + | Self::RepresentationInvalid => 400, Self::MissingCredential | Self::InvalidCredential => 401, Self::ConsultationDenied => 403, - Self::ResourceNotFound | Self::ConsultationUnresolved => 404, + Self::ResourceNotFound + | Self::ConsultationUnresolved + | Self::RepresentationNotFound => 404, Self::UnsupportedRepresentation => 406, Self::BodyTooLarge => 413, Self::UriTooLong => 414, @@ -166,6 +175,8 @@ impl ProblemCode { Self::UnknownFilter => "filter is not declared for this operation", Self::InvalidFilter => "filter value is invalid", Self::CursorInvalid => "cursor is invalid for this query", + Self::RepresentationInvalid => "representation selection is invalid", + Self::RepresentationNotFound => "the requested representation was not found", Self::MissingCredential => "a bearer access token is required", Self::InvalidCredential => "bearer access token validation failed", Self::ConsultationDenied => "the consultation is not permitted", diff --git a/crates/registry-relay-v2/src/semantics.rs b/crates/registry-relay-v2/src/semantics.rs index b6b76d53c..326b665f8 100644 --- a/crates/registry-relay-v2/src/semantics.rs +++ b/crates/registry-relay-v2/src/semantics.rs @@ -289,6 +289,16 @@ fn property_schema(registry: &CompiledRegistry, property: &CompiledProperty) -> DataType::Integer => json!({"type": "integer"}), DataType::Date => json!({"type": "string", "format": "date"}), DataType::DateTime => json!({"type": "string", "format": "date-time"}), + DataType::Year => json!({ + "type": "string", + "pattern": "^[0-9]{4}$", + "x-registry-datatype": "year" + }), + DataType::YearMonth => json!({ + "type": "string", + "pattern": "^[0-9]{4}-(0[1-9]|1[0-2])$", + "x-registry-datatype": "year-month" + }), } } @@ -307,6 +317,8 @@ pub fn datatype_iri(data_type: DataType) -> &'static str { DataType::Integer => "http://www.w3.org/2001/XMLSchema#integer", DataType::Date => "http://www.w3.org/2001/XMLSchema#date", DataType::DateTime => "http://www.w3.org/2001/XMLSchema#dateTime", + DataType::Year => "http://www.w3.org/2001/XMLSchema#gYear", + DataType::YearMonth => "http://www.w3.org/2001/XMLSchema#gYearMonth", } } @@ -357,6 +369,7 @@ mod tests { label: "Name".into(), description: "Name".into(), source_column: "name".into(), + transform: None, data_type: DataType::String, codelist: None, source_required: true, @@ -403,6 +416,7 @@ mod tests { local_vocabulary: "https://example.invalid/vocab/".into(), semantic_alignments: Vec::new(), governed_files: Vec::new(), + classification_review: None, codelists: Vec::new(), sources: Vec::new(), resources: Vec::new(), diff --git a/crates/registry-relay-v2/src/server.rs b/crates/registry-relay-v2/src/server.rs index c24b1c243..028ce463d 100644 --- a/crates/registry-relay-v2/src/server.rs +++ b/crates/registry-relay-v2/src/server.rs @@ -288,13 +288,13 @@ impl QuotaLimiter { } } - pub(crate) fn admit(&self, scope: &str) -> bool { + pub(crate) fn admit(&self, operation: &str) -> bool { let now = Instant::now(); let mut states = self .states .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - let state = states.entry(scope.to_owned()).or_insert(QuotaState { + let state = states.entry(operation.to_owned()).or_insert(QuotaState { tokens: self.burst, observed_at: now, }); diff --git a/crates/registry-relay-v2/src/sqlite_runtime.rs b/crates/registry-relay-v2/src/sqlite_runtime.rs index c98d32e79..968c6ff01 100644 --- a/crates/registry-relay-v2/src/sqlite_runtime.rs +++ b/crates/registry-relay-v2/src/sqlite_runtime.rs @@ -20,7 +20,9 @@ use tokio::sync::{OwnedSemaphorePermit, Semaphore}; use crate::auth::RowAuthority; use crate::contract::{DataType, SourceProfile}; -use crate::model::{CompiledOperation, CompiledRegistry, CompiledResource, OperationKind}; +use crate::model::{ + CompiledOperation, CompiledRegistry, CompiledRepresentation, CompiledResource, OperationKind, +}; const MAXIMUM_CELL_BYTES: usize = 1024 * 1024; const MAXIMUM_RESPONSE_BYTES: usize = 8 * 1024 * 1024; @@ -91,7 +93,13 @@ pub enum SqliteRuntimeError { struct OperationExecutor { statement: Arc, operation: CompiledOperation, + representation: CompiledRepresentation, + source_revision: SourceRevision, +} + +struct OperationInventory { source_revision: SourceRevision, + representations: BTreeMap, } #[derive(Clone)] @@ -102,7 +110,7 @@ struct ReadinessSource { /// Fixed operation inventory over one compiled Registry. pub struct SqliteRuntime { - operations: BTreeMap, + operations: BTreeMap, readiness_sources: Vec, admission: Arc, timeout: Duration, @@ -168,23 +176,41 @@ impl SqliteRuntime { .iter() .find(|source| source.id == operation.query.source) .ok_or(SqliteRuntimeError::MissingSource)?; - let contract = statement_contract( - resource, - operation, - &limits, - &source.expected_schema_fingerprint, - )?; - let statement = ReadOnlyStatement::open(profile.clone(), contract)?; - if operations - .insert( - operation.identifier.clone(), - OperationExecutor { - statement: Arc::new(statement), - operation: operation.clone(), - source_revision: source_revision.clone(), - }, - ) - .is_some() + let mut representations = BTreeMap::new(); + for representation in &operation.representations { + let contract = statement_contract( + resource, + operation, + representation, + &limits, + &source.expected_schema_fingerprint, + )?; + let statement = ReadOnlyStatement::open(profile.clone(), contract)?; + if representations + .insert( + representation.id.clone(), + OperationExecutor { + statement: Arc::new(statement), + operation: operation.clone(), + representation: representation.clone(), + source_revision: source_revision.clone(), + }, + ) + .is_some() + { + return Err(SqliteRuntimeError::InvalidPlan); + } + } + if representations.is_empty() + || operations + .insert( + operation.identifier.clone(), + OperationInventory { + source_revision: source_revision.clone(), + representations, + }, + ) + .is_some() { return Err(SqliteRuntimeError::InvalidPlan); } @@ -238,14 +264,16 @@ impl SqliteRuntime { pub async fn execute( &self, operation: &str, + representation: &str, query: OperationQuery, ) -> Result { let executor = self .operations .get(operation) + .and_then(|inventory| inventory.representations.get(representation)) .ok_or(SqliteRuntimeError::UnknownOperation)?; let permit = self.acquire().await?; - let values = bind_operation_values(&executor.operation, query)?; + let values = bind_operation_values(&executor.operation, &executor.representation, query)?; let result = executor.statement.execute(&values).await; drop(permit); Ok(OperationResult { @@ -265,10 +293,11 @@ impl SqliteRuntime { fn statement_contract( resource: &CompiledResource, operation: &CompiledOperation, + representation: &CompiledRepresentation, limits: &SqliteRuntimeLimits, expected_schema_fingerprint: &str, ) -> Result { - let result_columns = result_columns(operation); + let result_columns = result_columns(operation, representation); let columns = result_columns .iter() .map(|column| ColumnContract { @@ -278,9 +307,19 @@ fn statement_contract( .collect::>(); let mut parameters = Vec::new(); let sql = match &operation.kind { - OperationKind::List => list_sql(operation, &result_columns, &mut parameters), - OperationKind::Read => read_sql(resource, operation, &result_columns, &mut parameters), - OperationKind::Lookup { .. } => lookup_sql(operation, &result_columns, &mut parameters), + OperationKind::List => { + list_sql(operation, representation, &result_columns, &mut parameters) + } + OperationKind::Read => read_sql( + resource, + operation, + representation, + &result_columns, + &mut parameters, + ), + OperationKind::Lookup { .. } => { + lookup_sql(operation, representation, &result_columns, &mut parameters) + } }; let maximum_rows = match &operation.kind { OperationKind::List => u64::from( @@ -304,8 +343,9 @@ fn statement_contract( maximum_response_bytes: MAXIMUM_RESPONSE_BYTES, maximum_statement_steps: MAXIMUM_STATEMENT_STEPS, timeout: limits.request_timeout, - // Aggregate process concurrency is owned above. One connection per - // fixed operation prevents connection count from multiplying again. + // Aggregate process concurrency is owned above. Each fixed + // representation has one connection, and compilation bounds the + // Registry-wide representation executor inventory. concurrency: 1, }, schema: Some(SchemaBinding { @@ -316,8 +356,11 @@ fn statement_contract( }) } -fn result_columns(operation: &CompiledOperation) -> Vec { - let mut columns = operation.query.projected_columns.clone(); +fn result_columns( + operation: &CompiledOperation, + representation: &CompiledRepresentation, +) -> Vec { + let mut columns = representation.projected_columns.clone(); for column in &operation.query.order_by { if !columns.contains(column) { columns.push(column.clone()); @@ -339,14 +382,18 @@ fn data_type(value: DataType) -> ColumnType { match value { DataType::Boolean => ColumnType::Boolean, DataType::Integer => ColumnType::Integer, - DataType::String | DataType::Date | DataType::DateTime | DataType::ControlledCode => { - ColumnType::String - } + DataType::String + | DataType::Date + | DataType::DateTime + | DataType::Year + | DataType::YearMonth + | DataType::ControlledCode => ColumnType::String, } } fn list_sql( operation: &CompiledOperation, + representation: &CompiledRepresentation, columns: &[String], parameters: &mut Vec, ) -> String { @@ -361,7 +408,7 @@ fn list_sql( quote_identifier(&filter.source_column) )); } - add_row_authority(operation, parameters, &mut predicates); + add_row_authority(representation, parameters, &mut predicates); parameters.push(parameter("cursor_present")); let keyset = keyset_predicate(&operation.query.order_by, parameters); predicates.push(format!("(:cursor_present = 0 OR ({keyset}))")); @@ -384,6 +431,7 @@ fn list_sql( fn read_sql( resource: &CompiledResource, operation: &CompiledOperation, + representation: &CompiledRepresentation, columns: &[String], parameters: &mut Vec, ) -> String { @@ -392,7 +440,7 @@ fn read_sql( "{} = :record_identifier", quote_identifier(&resource.record_context.record_identifier_column) )]; - add_row_authority(operation, parameters, &mut predicates); + add_row_authority(representation, parameters, &mut predicates); format!( "SELECT {} FROM {} WHERE {} LIMIT 2", select_list(columns), @@ -403,6 +451,7 @@ fn read_sql( fn lookup_sql( operation: &CompiledOperation, + representation: &CompiledRepresentation, columns: &[String], parameters: &mut Vec, ) -> String { @@ -415,7 +464,7 @@ fn lookup_sql( quote_identifier(&selector.source_column) )); } - add_row_authority(operation, parameters, &mut predicates); + add_row_authority(representation, parameters, &mut predicates); format!( "SELECT {} FROM {} WHERE {} LIMIT 2", select_list(columns), @@ -425,14 +474,14 @@ fn lookup_sql( } fn add_row_authority( - operation: &CompiledOperation, + representation: &CompiledRepresentation, parameters: &mut Vec, predicates: &mut Vec, ) { if let crate::model::CompiledAccess::Protected { row_binding: Some(binding), .. - } = &operation.access + } = &representation.access { parameters.push(parameter("row_authority")); predicates.push(format!( @@ -482,6 +531,7 @@ fn quote_identifier(value: &str) -> String { fn bind_operation_values( operation: &CompiledOperation, + representation: &CompiledRepresentation, query: OperationQuery, ) -> Result, SqliteRuntimeError> { let mut values = BTreeMap::new(); @@ -558,7 +608,7 @@ fn bind_operation_values( if let crate::model::CompiledAccess::Protected { row_binding: Some(binding), .. - } = &operation.access + } = &representation.access { let row = query.row_authority.ok_or(SqliteRuntimeError::InvalidPlan)?; if row.source_column != binding.source_column { diff --git a/crates/registry-relay-v2/src/startup.rs b/crates/registry-relay-v2/src/startup.rs index eb78e8a83..52557361f 100644 --- a/crates/registry-relay-v2/src/startup.rs +++ b/crates/registry-relay-v2/src/startup.rs @@ -486,21 +486,24 @@ fn validate_runtime_contract( .operations .list .iter() - .map(|operation| &operation.access) - .chain( - resource - .operations - .read + .flat_map(|operation| { + operation + .representations .iter() - .map(|operation| &operation.access), - ) - .chain( - resource - .operations - .lookups + .map(|(_, item)| &item.access) + }) + .chain(resource.operations.read.iter().flat_map(|operation| { + operation + .representations .iter() - .map(|operation| &operation.access), - ) + .map(|(_, item)| &item.access) + })) + .chain(resource.operations.lookups.iter().flat_map(|operation| { + operation + .representations + .iter() + .map(|(_, item)| &item.access) + })) .any(|access| matches!(access, AccessRule::Protected(_))) }); if protected && runtime.authentication.issuer.is_none() { @@ -1001,8 +1004,9 @@ metadataVisibility: {service: public, resources: public, semantics: public, clas RegistryContract::parse_yaml(&yaml).expect("generic contract") } - let protected = - contract("{read: {access: {scope: registry:record:read}, disclosureProfile: default}}"); + let protected = contract( + "{read: {defaultRepresentation: default, representations: {default: {access: {scope: registry:record:read}, disclosureProfile: default}}}}", + ); let protected_runtime = RelayRuntime::parse_yaml( "apiVersion: relay.registrystack.org/v2alpha1\nkind: RelayRuntime\nserver: {bind: '127.0.0.1:18081'}\npackagePath: package\nsources: {records: {path: fixture.sqlite}}\nauthentication: {issuer: null}\naudit: {sink: var/audit.jsonl, integrityKeyRef: secret:env/KEY}\nlimits: {requestTimeoutMilliseconds: 1000, concurrentQueries: 1}\n", ) @@ -1013,7 +1017,7 @@ metadataVisibility: {service: public, resources: public, semantics: public, clas ); let list = contract( - "{list: {access: public, disclosureProfile: default, filters: [], allowUnfiltered: true, orderBy: [id], pagination: {defaultPageSize: 10, maximumPageSize: 20}}}", + "{list: {defaultRepresentation: default, representations: {default: {access: public, disclosureProfile: default}}, filters: [], allowUnfiltered: true, orderBy: [id], pagination: {defaultPageSize: 10, maximumPageSize: 20}}}", ); let list_runtime = RelayRuntime::parse_yaml( "apiVersion: relay.registrystack.org/v2alpha1\nkind: RelayRuntime\nserver: {bind: '127.0.0.1:18082'}\npackagePath: package\nsources: {records: {path: fixture.sqlite}}\nauthentication: {issuer: null}\naudit: {sink: var/audit.jsonl, integrityKeyRef: secret:env/KEY}\nlimits: {requestTimeoutMilliseconds: 1000, concurrentQueries: 1}\n", @@ -1025,7 +1029,7 @@ metadataVisibility: {service: public, resources: public, semantics: public, clas ); let lookup = contract( - "{lookups: [{id: by-label, access: public, requestBody: {maximumBytes: 128, selectors: {label: {sourceColumn: label, type: string, minimumBytes: 1, maximumBytes: 32}}}, disclosureProfile: default}]}", + "{lookups: [{id: by-label, requestBody: {maximumBytes: 128, selectors: {label: {sourceColumn: label, type: string, minimumBytes: 1, maximumBytes: 32}}}, defaultRepresentation: default, representations: {default: {access: public, disclosureProfile: default}}}]}", ); let mut lookup_runtime = RelayRuntime::parse_yaml( "apiVersion: relay.registrystack.org/v2alpha1\nkind: RelayRuntime\nserver: {bind: '127.0.0.1:18083'}\npackagePath: package\nsources: {records: {path: fixture.sqlite}}\nauthentication: {issuer: null}\naudit: {sink: var/audit.jsonl, integrityKeyRef: secret:env/KEY}\nlimits: {requestTimeoutMilliseconds: 1000, concurrentQueries: 1}\n", diff --git a/crates/registry-relay-v2/src/tooling.rs b/crates/registry-relay-v2/src/tooling.rs index 85f474ae8..e34f818d1 100644 --- a/crates/registry-relay-v2/src/tooling.rs +++ b/crates/registry-relay-v2/src/tooling.rs @@ -13,17 +13,28 @@ use registry_platform_sqlite::{ DatabaseProfile, SchemaObjectKind, }; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use thiserror::Error; use crate::artifacts::{generate_artifacts, ArtifactSet}; use crate::audit::RelayAudit; -use crate::compiler::{compile_contract_with_governed_files, GovernedFileSet}; -use crate::contract::{RegistryContract, RelayRuntime}; +use crate::compiler::{ + classification_inventory_digest, compile_contract_with_governed_files, GovernedFileSet, +}; +use crate::contract::{ClassificationReviewDocument, RegistryContract, RelayRuntime}; use crate::cursor::CursorKey; use crate::diff::{diff_registries, ChangeImpactReport}; use crate::fixtures::{ execute_fixture_journey, fixture_authenticator, parse_journey, FixturePlanReport, }; +use crate::identification::{ + classification_inventory_report, classification_review_starter, contextual_review_findings, + identify_contract, render_classification_inventory_report, render_classification_review_yaml, + render_contextual_review_findings, render_identification_report, render_representation_report, + representation_report, CLASSIFICATION_INVENTORY_REPORT_PATH, + CLASSIFICATION_REVIEW_STARTER_PATH, CONTEXTUAL_REVIEW_FINDINGS_PATH, + IDENTIFICATION_REPORT_PATH, REPRESENTATION_REPORT_PATH, +}; use crate::model::{ CompileProfile, CompileReport, CompiledRegistry, Diagnostic, DiagnosticSeverity, }; @@ -413,14 +424,59 @@ pub fn generate_project(options: &GenerateOptions) -> Result Result>(); + for (id, path, content) in authoring_outputs { + write_generated_relative(&output, path, &content)?; + generated.push(GeneratedFile { + id: id.into(), + path: path.into(), + sha256: format!("sha256:{}", hex::encode(Sha256::digest(&content))), + }); + } + generated.sort_by(|left, right| left.path.cmp(&right.path).then(left.id.cmp(&right.id))); Ok(ToolingReport::success(ToolingDetails::Generate { contract_revision: Some(registry.contract_revision), artifacts: generated, @@ -453,6 +518,7 @@ pub fn test_project(options: &TestOptions) -> Result, registry: CompiledRegistry, + observed: Vec, } fn compile_project( @@ -767,6 +834,7 @@ fn compile_project( contract, runtime, registry, + observed, }))) } Ok(_) => Ok(ProjectCompilation::Refused(CompileReport { diagnostics })), @@ -787,28 +855,46 @@ fn capture_governed_files( contract: &RegistryContract, ) -> Result { let mut references = BTreeSet::new(); - references.insert(contract.registry.identifier_lifecycle_policy_ref.as_str()); - references.insert(contract.classifications.provenance_ref.as_str()); + references.insert(contract.registry.identifier_lifecycle_policy_ref.clone()); + references.insert(contract.classifications.provenance_ref.clone()); for alignment in &contract.semantics.alignments { - references.insert(alignment.profile_ref.as_str()); + references.insert(alignment.profile_ref.clone()); } for resource in &contract.resources { - references.insert(resource.record_context.lifecycle_state.codelist.as_str()); + references.insert(resource.record_context.lifecycle_state.codelist.clone()); for (_, property) in resource.properties.iter() { if let Some(codelist) = property.codelist.as_deref() { - references.insert(codelist); + references.insert(codelist.to_owned()); } } for processing in &resource.processing_descriptions { - references.insert(processing.legal_basis_ref.as_str()); - references.insert(processing.dpv_profile_ref.as_str()); + references.insert(processing.legal_basis_ref.clone()); + references.insert(processing.dpv_profile_ref.clone()); } } let canonical_root = root.canonicalize().map_err(|_| ToolingError::Read)?; + let review_reference = contract.classifications.provenance_ref.as_str(); + validate_relative(review_reference)?; + reject_existing_symlink_components(&canonical_root, Path::new(review_reference))?; + let review_path = canonical_root.join(review_reference); + if review_path.is_file() { + let review_bytes = fs::read(&review_path).map_err(|_| ToolingError::Read)?; + if review_bytes.len() <= 64 * 1024 { + if let Ok(review) = + serde_norway::from_slice::(&review_bytes) + { + references.insert(review.rationale_ref); + if let Some(generated) = review.generated_identification { + references.insert(generated.report_ref); + } + } + } + } let mut files = GovernedFileSet::new(); for reference in references { - validate_relative(reference)?; - let candidate = canonical_root.join(reference); + validate_relative(&reference)?; + reject_existing_symlink_components(&canonical_root, Path::new(&reference))?; + let candidate = canonical_root.join(&reference); let Ok(metadata) = fs::symlink_metadata(&candidate) else { continue; }; @@ -820,7 +906,7 @@ fn capture_governed_files( return Err(ToolingError::UnsafePath); } files.insert( - reference.into(), + reference, fs::read(canonical).map_err(|_| ToolingError::Read)?, ); } @@ -870,21 +956,24 @@ fn validate_runtime( .operations .list .iter() - .map(|operation| &operation.access) - .chain( - resource - .operations - .read + .flat_map(|operation| { + operation + .representations .iter() - .map(|operation| &operation.access), - ) - .chain( - resource - .operations - .lookups + .map(|(_, item)| &item.access) + }) + .chain(resource.operations.read.iter().flat_map(|operation| { + operation + .representations .iter() - .map(|operation| &operation.access), - ) + .map(|(_, item)| &item.access) + })) + .chain(resource.operations.lookups.iter().flat_map(|operation| { + operation + .representations + .iter() + .map(|(_, item)| &item.access) + })) .any(|access| matches!(access, crate::contract::AccessRule::Protected(_))) }); if protected && runtime.authentication.issuer.is_none() { @@ -928,6 +1017,20 @@ fn write_artifacts(output: &Path, artifacts: &ArtifactSet) -> Result<(), Tooling Ok(()) } +fn write_generated_relative( + output: &Path, + relative: &str, + content: &[u8], +) -> Result<(), ToolingError> { + validate_relative(relative)?; + reject_existing_symlink_components(output, Path::new(relative))?; + let path = output.join(relative); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|_| ToolingError::Write)?; + } + fs::write(path, content).map_err(|_| ToolingError::Write) +} + fn reject_existing_symlink_components(root: &Path, relative: &Path) -> Result<(), ToolingError> { let mut current = root.to_path_buf(); for component in relative.components() { @@ -1026,7 +1129,10 @@ resources: recordValue: {label: Record value, description: Unreviewed starter property, sourceColumn: record_value, type: string, sourceRequired: true, semanticTerm: "local:recordValue"} disclosureProfiles: {default: {properties: [recordValue]}} operations: - read: {access: {scope: "registry:record:read"}, disclosureProfile: default} + read: + defaultRepresentation: default + representations: + default: {access: {scope: "registry:record:read"}, disclosureProfile: default} processingDescriptions: - {id: consultation, operationRefs: [read], purpose: reviewed-consultation, recipientClass: authorized-client, legalBasisRef: governance/legal-basis.yaml, dpvProfileRef: governance/processing.dpv.yaml, safeguards: [property-minimization]} metadataVisibility: {service: public, resources: operation-bound, semantics: operation-bound, classifications: operator-only, processing: operation-bound} @@ -1044,8 +1150,16 @@ limits: {requestTimeoutMilliseconds: 1500, concurrentQueries: 8} const STARTER_LIFECYCLE: &str = "status: suggested\npolicy: Identifiers are stable and are not reassigned after retirement.\n"; -const STARTER_CLASSIFICATION: &str = - "status: suggested\nreview: Institutional review is required before production packaging.\n"; +const STARTER_CLASSIFICATION: &str = r#"apiVersion: relay.registrystack.org/classification-review/v1 +kind: ClassificationReview +registryIdentifier: urn:example:registry:registry +classificationInventoryDigest: sha256:0000000000000000000000000000000000000000000000000000000000000000 +method: manual +reviewer: urn:example:authority +reviewDate: pending-review +status: suggested +rationaleRef: governance/legal-basis.yaml +"#; const STARTER_LEGAL_BASIS: &str = "status: suggested\nlegalBasis: Institutional review is required before production packaging.\n"; const STARTER_PROCESSING: &str = "status: suggested\nprofile: https://w3id.org/dpv/2.3\n"; const STARTER_CODELIST: &str = diff --git a/crates/registry-relay-v2/src/transform.rs b/crates/registry-relay-v2/src/transform.rs new file mode 100644 index 000000000..7cdf4d088 --- /dev/null +++ b/crates/registry-relay-v2/src/transform.rs @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Pure, closed transforms for compiled Relay representations. + +use chrono::{DateTime, NaiveDate}; +use registry_platform_sqlite::Value; + +use crate::contract::{DateInputType, DatePrecision, PartialStringReveal}; +use crate::model::CompiledTransform; + +pub const PARTIAL_STRING_MARKER: &str = "***"; +const MAXIMUM_TRANSFORM_INPUT_BYTES: usize = 4096; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct TransformError; + +pub fn apply(transform: &CompiledTransform, value: &Value) -> Result { + let Value::String(value) = value else { + return Err(TransformError); + }; + if value.len() > MAXIMUM_TRANSFORM_INPUT_BYTES || value.chars().any(char::is_control) { + return Err(TransformError); + } + match transform { + CompiledTransform::PartialString { + reveal, characters, .. + } => partial_string(value, *reveal, *characters), + CompiledTransform::DatePrecision { + source_type, + precision, + .. + } => date_precision(value, *source_type, *precision), + } + .map(Value::String) +} + +fn partial_string( + value: &str, + reveal: PartialStringReveal, + characters: u16, +) -> Result { + let reveal_count = usize::from(characters); + if reveal_count == 0 { + return Err(TransformError); + } + let values = value.chars().collect::>(); + if values.len() <= reveal_count { + return Ok(PARTIAL_STRING_MARKER.to_owned()); + } + let revealed = match reveal { + PartialStringReveal::Prefix => values[..reveal_count].iter().collect::(), + PartialStringReveal::Suffix => values[values.len() - reveal_count..] + .iter() + .collect::(), + }; + Ok(match reveal { + PartialStringReveal::Prefix => format!("{revealed}{PARTIAL_STRING_MARKER}"), + PartialStringReveal::Suffix => format!("{PARTIAL_STRING_MARKER}{revealed}"), + }) +} + +fn date_precision( + value: &str, + source_type: DateInputType, + precision: DatePrecision, +) -> Result { + let date = match source_type { + DateInputType::Date => { + NaiveDate::parse_from_str(value, "%Y-%m-%d").map_err(|_| TransformError)? + } + DateInputType::DateTime => DateTime::parse_from_rfc3339(value) + .map_err(|_| TransformError)? + .date_naive(), + }; + Ok(match precision { + DatePrecision::Year => date.format("%Y").to_string(), + DatePrecision::YearMonth => date.format("%Y-%m").to_string(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn partial_string_never_reveals_a_complete_short_input() { + let suffix = CompiledTransform::PartialString { + identifier: "partial-string:suffix:2".into(), + reveal: PartialStringReveal::Suffix, + characters: 2, + }; + assert_eq!( + apply(&suffix, &Value::String("กข".into())).unwrap(), + Value::String(PARTIAL_STRING_MARKER.into()) + ); + } + + #[test] + fn partial_string_counts_unicode_scalars() { + let suffix = CompiledTransform::PartialString { + identifier: "partial-string:suffix:2".into(), + reveal: PartialStringReveal::Suffix, + characters: 2, + }; + assert_eq!( + apply(&suffix, &Value::String("Aกข".into())).unwrap(), + Value::String("***กข".into()) + ); + } + + #[test] + fn transforms_reject_wrong_type_and_oversized_input() { + let partial = CompiledTransform::PartialString { + identifier: "partial-string:suffix:2".into(), + reveal: PartialStringReveal::Suffix, + characters: 2, + }; + let date = CompiledTransform::DatePrecision { + identifier: "date-precision:date:year".into(), + source_type: DateInputType::Date, + precision: DatePrecision::Year, + }; + assert_eq!(apply(&partial, &Value::Integer(42)), Err(TransformError)); + let oversized = Value::String("A".repeat(MAXIMUM_TRANSFORM_INPUT_BYTES + 1)); + assert_eq!(apply(&partial, &oversized), Err(TransformError)); + assert_eq!(apply(&date, &oversized), Err(TransformError)); + } + + #[test] + fn date_precision_accepts_only_the_compiled_source_shape() { + let year_month = CompiledTransform::DatePrecision { + identifier: "date-precision:date:year-month".into(), + source_type: DateInputType::Date, + precision: DatePrecision::YearMonth, + }; + assert_eq!( + apply(&year_month, &Value::String("2026-08-10".into())).unwrap(), + Value::String("2026-08".into()) + ); + assert!(apply(&year_month, &Value::String("10/08/2026".into())).is_err()); + assert!(apply(&year_month, &Value::Null).is_err()); + } +} diff --git a/crates/registry-relay-v2/tests/acceptance_http.rs b/crates/registry-relay-v2/tests/acceptance_http.rs index a6d860207..d2afaf62c 100644 --- a/crates/registry-relay-v2/tests/acceptance_http.rs +++ b/crates/registry-relay-v2/tests/acceptance_http.rs @@ -30,6 +30,7 @@ use registry_relay_v2::audit::RelayAudit; use registry_relay_v2::auth::RelayAuthenticator; use registry_relay_v2::compiler::{compile_contract_with_governed_files, GovernedFileSet}; use registry_relay_v2::contract::{RegistryContract, RelayRuntime}; +use registry_relay_v2::identification::parse_classification_review_yaml; use registry_relay_v2::model::{ CompileProfile, ObservedColumn, ObservedSourceSchema, ObservedView, }; @@ -244,9 +245,8 @@ async fn all_three_registry_http_journeys_use_the_real_router() { assert_eq!( status, StatusCode::from_u16(step.expect.status).expect("expected status is valid"), - "{project}/{} returned the wrong status; body={}", - step.id, - String::from_utf8_lossy(&body) + "{project}/{} returned the wrong status; response body withheld", + step.id ); if let Some(reference) = &step.expect.etag_same_as { assert_eq!( @@ -758,7 +758,7 @@ async fn operation_bound_metadata_is_no_store_and_links_only_visible_artifacts() capability["processingReference"] .as_str() .is_some_and(|reference| reference.ends_with( - "/v2/artifacts/assistance-enrolment--lookup-by-case-and-person-processing" + "/v2/artifacts/assistance-enrolment--lookup-by-case-and-person--representation-limited-processing" )), "processing metadata link resolves to the mounted artifact identifier" ); @@ -1029,23 +1029,34 @@ fn assert_expectations( ); } if let Some(cache) = &step.expect.cache { - assert_eq!( - cache, "public-snapshot-revalidation", - "{label} cache expectation" - ); - assert_eq!( - headers - .get(CACHE_CONTROL) - .and_then(|value| value.to_str().ok()), - Some("public, no-cache"), - "{label} cache-control" - ); - assert!(headers.contains_key(ETAG), "{label} requires an ETag"); - assert_eq!( - headers.get(VARY).and_then(|value| value.to_str().ok()), - Some("Accept, Authorization"), - "{label} vary" - ); + match cache.as_str() { + "public-snapshot-revalidation" => { + assert_eq!( + headers + .get(CACHE_CONTROL) + .and_then(|value| value.to_str().ok()), + Some("public, no-cache"), + "{label} cache-control" + ); + assert!(headers.contains_key(ETAG), "{label} requires an ETag"); + assert_eq!( + headers.get(VARY).and_then(|value| value.to_str().ok()), + Some("Accept, Authorization"), + "{label} vary" + ); + } + "no-store" => { + assert_eq!( + headers + .get(CACHE_CONTROL) + .and_then(|value| value.to_str().ok()), + Some("no-store"), + "{label} cache-control" + ); + assert!(!headers.contains_key(ETAG), "{label} omits an ETag"); + } + unsupported => panic!("{label} has unsupported cache expectation {unsupported}"), + } } let body_text = String::from_utf8_lossy(body); for absent in &step.expect.absent_everywhere { @@ -1514,6 +1525,14 @@ fn governed_files(root: &Path, contract: &RegistryContract) -> GovernedFileSet { let mut paths = BTreeSet::new(); paths.insert(contract.registry.identifier_lifecycle_policy_ref.clone()); paths.insert(contract.classifications.provenance_ref.clone()); + let review_bytes = fs::read(root.join(&contract.classifications.provenance_ref)) + .expect("classification review reads"); + let review = parse_classification_review_yaml(&review_bytes) + .expect("classification review strictly parses"); + paths.insert(review.rationale_ref); + if let Some(generated) = review.generated_identification { + paths.insert(generated.report_ref); + } for alignment in &contract.semantics.alignments { paths.insert(alignment.profile_ref.clone()); } diff --git a/crates/registry-relay-v2/tests/identification.rs b/crates/registry-relay-v2/tests/identification.rs new file mode 100644 index 000000000..411575f7d --- /dev/null +++ b/crates/registry-relay-v2/tests/identification.rs @@ -0,0 +1,679 @@ +// SPDX-License-Identifier: Apache-2.0 + +use registry_relay_v2::compiler::{classification_inventory_digest, compile_contract}; +use registry_relay_v2::contract::{ + ClassificationReviewDocument, DataType, GeneratedIdentificationBinding, IdentificationMethod, + PartialStringReveal, RegistryContract, ReviewStatus, +}; +use registry_relay_v2::identification::{ + classification_inventory_report, classification_review_starter, contextual_review_findings, + core_pack_reference, identification_report_digest, identify_contract, + parse_classification_review_yaml, render_classification_inventory_report, + render_classification_review_yaml, render_contextual_review_findings, + render_identification_report, render_representation_report, representation_report, + validate_classification_review, CategoricalConfidence, ClassificationReviewExpectation, + IdentificationError, IdentificationStatus, TechnicalRole, REVIEWED_IDENTIFICATION_REPORT_PATH, +}; +use registry_relay_v2::model::{ + CompileProfile, CompiledSelector, CompiledTransform, ObservedColumn, ObservedSourceSchema, + ObservedView, OperationKind, +}; +use sha2::{Digest, Sha256}; + +#[test] +fn core_pack_digest_is_pinned_and_carried_by_every_candidate() { + let reference = core_pack_reference().expect("embedded pack verifies"); + let bytes = include_bytes!("../assets/identification/core-pack-v1.json"); + assert_eq!( + reference.digest, + format!("sha256:{}", hex::encode(Sha256::digest(bytes))) + ); + assert_eq!(reference.id, "registrystack.relay.identification.core"); + assert_eq!(reference.version, "1"); + + let report = identify_contract(&contract(), &[observed(false)]).expect("identifies"); + assert!(report + .candidates + .iter() + .all(|candidate| candidate.rule_pack == reference)); +} + +#[test] +fn report_bytes_are_deterministic_across_observation_order() { + let first = identify_contract(&contract(), &[observed(false)]).expect("first report"); + let second = identify_contract(&contract(), &[observed(true)]).expect("second report"); + assert_eq!( + render_identification_report(&first).expect("first bytes"), + render_identification_report(&second).expect("second bytes") + ); + assert_eq!( + identification_report_digest(&first).expect("first digest"), + identification_report_digest(&second).expect("second digest") + ); +} + +#[test] +fn technical_families_use_schema_and_authored_roles_only() { + let report = identify_contract(&contract(), &[observed(false)]).expect("identifies"); + assert_candidate( + &report, + "id", + CategoricalConfidence::Exact, + IdentificationStatus::Suggested, + Some(TechnicalRole::RecordIdentifier), + ); + assert_candidate( + &report, + "revision", + CategoricalConfidence::Exact, + IdentificationStatus::Suggested, + Some(TechnicalRole::RevisionIdentifier), + ); + assert_candidate( + &report, + "status", + CategoricalConfidence::Exact, + IdentificationStatus::Suggested, + Some(TechnicalRole::LifecycleState), + ); + assert_candidate( + &report, + "recorded_at", + CategoricalConfidence::Exact, + IdentificationStatus::Suggested, + Some(TechnicalRole::RecordedTime), + ); + assert_candidate( + &report, + "region_code", + CategoricalConfidence::Strong, + IdentificationStatus::Suggested, + Some(TechnicalRole::GeographicCode), + ); + assert_candidate( + &report, + "category_code", + CategoricalConfidence::Exact, + IdentificationStatus::Suggested, + Some(TechnicalRole::Codelist), + ); + assert_candidate( + &report, + "person_reference", + CategoricalConfidence::Strong, + IdentificationStatus::Suggested, + Some(TechnicalRole::PersonReference), + ); + assert_candidate( + &report, + "notes", + CategoricalConfidence::Weak, + IdentificationStatus::Suggested, + Some(TechnicalRole::Property), + ); +} + +#[test] +fn generic_fallback_applies_only_when_no_specific_rule_matches() { + let report = identify_contract(&contract(), &[observed(false)]).expect("identifies"); + let identified = report + .candidates + .iter() + .find(|candidate| candidate.source_column == "id") + .expect("identified candidate"); + assert!(identified + .matched_rules + .iter() + .any(|rule| rule.id == "core.role.record-identifier")); + assert!(!identified + .matched_rules + .iter() + .any(|rule| rule.id == "core.column.fallback")); + + let fallback = report + .candidates + .iter() + .find(|candidate| candidate.source_column == "notes") + .expect("fallback candidate"); + assert_eq!(fallback.matched_rules.len(), 1); + assert_eq!(fallback.matched_rules[0].id, "core.column.fallback"); + assert_eq!(fallback.suggested_role, Some(TechnicalRole::Property)); + + let weak_specific = report + .candidates + .iter() + .find(|candidate| candidate.source_column == "email_phone") + .expect("weak specific candidate"); + assert!(weak_specific + .matched_rules + .iter() + .all(|rule| rule.id != "core.column.fallback")); +} + +#[test] +fn privacy_suggestions_are_explicitly_local_candidates_not_configured_scheme_terms() { + let report = identify_contract(&contract(), &[observed(false)]).expect("identifies"); + assert_eq!( + report.privacy_candidate_vocabulary.scheme, + "urn:registrystack:relay:privacy-candidate" + ); + assert_eq!(report.privacy_candidate_vocabulary.version, "1"); + + let candidate = report + .candidates + .iter() + .find(|candidate| candidate.source_column == "person_reference") + .expect("privacy candidate"); + assert!(!candidate.suggested_privacy.is_empty()); + assert!(candidate.suggested_privacy.iter().all(|term| { + term.scheme == report.privacy_candidate_vocabulary.scheme + && term.version == report.privacy_candidate_vocabulary.version + && term.scheme != "urn:example:privacy" + })); + assert!(candidate + .suggested_privacy + .iter() + .any(|term| term.term == "identifying")); +} + +#[test] +fn credible_name_rules_conflict_without_selecting_a_winner() { + let report = identify_contract(&contract(), &[observed(false)]).expect("identifies"); + let candidate = report + .candidates + .iter() + .find(|candidate| candidate.source_column == "email_phone") + .expect("candidate"); + assert_eq!(candidate.confidence, CategoricalConfidence::Conflict); + assert_eq!(candidate.status, IdentificationStatus::Uncertain); + assert_eq!(candidate.suggested_role, None); + assert!(candidate + .matched_rules + .iter() + .any(|rule| rule.id == "core.name.email-token" && rule.version == "1")); + assert!(candidate + .matched_rules + .iter() + .any(|rule| rule.id == "core.name.telephone-token" && rule.version == "1")); + assert_eq!(report.diagnostics.len(), 1); + assert_eq!( + report.diagnostics[0].code, + "identification.candidate_conflict" + ); +} + +#[test] +fn source_row_value_canary_cannot_reach_report_or_diagnostics() { + // The identification API has no row-value argument. This value represents + // a row held by the source runtime and stays outside the observation. + let source_row_value = "ROW_VALUE_CANARY_4f310b7235"; + let report = identify_contract(&contract(), &[observed(false)]).expect("identifies"); + let bytes = render_identification_report(&report).expect("report bytes"); + let rendered = String::from_utf8(bytes).expect("JSON is UTF-8"); + assert!(!rendered.contains(source_row_value)); + assert!(report + .diagnostics + .iter() + .all(|diagnostic| !diagnostic.message.contains(source_row_value))); + + let value: serde_json::Value = serde_json::from_str(&rendered).expect("report JSON"); + assert_report_has_no_row_payload_fields(&value); +} + +#[test] +fn generated_starter_is_unreviewed_and_binds_recomputed_report_and_pack() { + let contract = contract(); + let observation = observed(false); + let report = + identify_contract(&contract, std::slice::from_ref(&observation)).expect("identifies"); + let registry = compile_contract(&contract, &[observation], CompileProfile::Authoring) + .expect("contract compiles"); + let inventory_digest = classification_inventory_digest(®istry).expect("inventory digest"); + let starter = classification_review_starter(&contract, &inventory_digest, &report) + .expect("starter renders"); + let generated = starter + .generated_identification + .clone() + .expect("generated binding"); + assert_eq!(generated.report_ref, REVIEWED_IDENTIFICATION_REPORT_PATH); + assert_eq!( + generated.report_digest, + identification_report_digest(&report).expect("report digest") + ); + assert_eq!(generated.rule_pack, core_pack_reference().expect("pack")); + assert_eq!( + render_classification_review_yaml(&starter).expect("first starter bytes"), + render_classification_review_yaml(&starter).expect("second starter bytes") + ); + + let validation = validate_classification_review( + &starter, + &ClassificationReviewExpectation { + registry_identifier: contract.registry.registry_identifier.clone(), + classification_inventory_digest: inventory_digest, + generated_identification: Some(generated), + }, + ); + assert!(!validation.is_valid()); + assert!(validation + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "classification.review_unreviewed")); +} + +#[test] +fn generated_review_refuses_stale_inventory_report_and_pack_bindings() { + let contract = contract(); + let report = identify_contract(&contract, &[observed(false)]).expect("identifies"); + let mut review = reviewed_generated(&contract, &report, digest('a')); + let current = review + .generated_identification + .clone() + .expect("generated binding"); + assert!(validate_classification_review( + &review, + &ClassificationReviewExpectation { + registry_identifier: contract.registry.registry_identifier.clone(), + classification_inventory_digest: digest('a'), + generated_identification: Some(current.clone()), + }, + ) + .is_valid()); + let stale_inventory = validate_classification_review( + &review, + &ClassificationReviewExpectation { + registry_identifier: contract.registry.registry_identifier.clone(), + classification_inventory_digest: digest('b'), + generated_identification: Some(current.clone()), + }, + ); + assert!(stale_inventory + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "classification.review_inventory_stale")); + + let mut expected_report = current.clone(); + expected_report.report_digest = digest('c'); + let stale_report = validate_classification_review( + &review, + &ClassificationReviewExpectation { + registry_identifier: contract.registry.registry_identifier.clone(), + classification_inventory_digest: digest('a'), + generated_identification: Some(expected_report), + }, + ); + assert!(stale_report + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "classification.review_identification_stale")); + + review + .generated_identification + .as_mut() + .expect("generated") + .rule_pack + .digest = digest('d'); + let stale_pack = validate_classification_review( + &review, + &ClassificationReviewExpectation { + registry_identifier: contract.registry.registry_identifier.clone(), + classification_inventory_digest: digest('a'), + generated_identification: Some(current), + }, + ); + assert!(stale_pack + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "classification.review_identification_stale")); +} + +#[test] +fn manual_and_imported_reviews_are_first_class_and_forbid_generated_evidence() { + let contract = contract(); + let report = identify_contract(&contract, &[observed(false)]).expect("identifies"); + let current_generated = GeneratedIdentificationBinding { + report_ref: REVIEWED_IDENTIFICATION_REPORT_PATH.into(), + report_digest: identification_report_digest(&report).expect("digest"), + rule_pack: core_pack_reference().expect("pack"), + }; + let expected = ClassificationReviewExpectation { + registry_identifier: contract.registry.registry_identifier.clone(), + classification_inventory_digest: digest('a'), + generated_identification: Some(current_generated.clone()), + }; + for method in [IdentificationMethod::Manual, IdentificationMethod::Imported] { + let review = ClassificationReviewDocument { + api_version: "relay.registrystack.org/classification-review/v1".into(), + kind: "ClassificationReview".into(), + registry_identifier: contract.registry.registry_identifier.clone(), + classification_inventory_digest: digest('a'), + method, + reviewer: "urn:example:reviewer".into(), + review_date: "2026-08-10".into(), + status: ReviewStatus::Reviewed, + rationale_ref: "governance/classification-rationale.yaml".into(), + generated_identification: None, + }; + assert!(validate_classification_review(&review, &expected).is_valid()); + + let bytes = render_classification_review_yaml(&review).expect("YAML renders"); + assert_eq!( + parse_classification_review_yaml(&bytes).expect("YAML parses"), + review + ); + } + + let invalid = ClassificationReviewDocument { + api_version: "relay.registrystack.org/classification-review/v1".into(), + kind: "ClassificationReview".into(), + registry_identifier: contract.registry.registry_identifier.clone(), + classification_inventory_digest: digest('a'), + method: IdentificationMethod::Manual, + reviewer: "urn:example:reviewer".into(), + review_date: "2026-08-10".into(), + status: ReviewStatus::Reviewed, + rationale_ref: "governance/classification-rationale.yaml".into(), + generated_identification: Some(current_generated), + }; + let validation = validate_classification_review(&invalid, &expected); + assert!(validation.diagnostics.iter().any(|diagnostic| { + diagnostic.code == "classification.review_generated_binding_forbidden" + })); +} + +#[test] +fn embedded_pack_and_fixed_diagnostics_remain_source_neutral() { + let pack = + String::from_utf8_lossy(include_bytes!("../assets/identification/core-pack-v1.json")); + for term in [ + "health", + "medical", + "civil-registration", + "social-protection", + "business-registry", + "jurisdiction", + ] { + assert!(!pack.contains(term), "pack contains domain term {term}"); + } +} + +#[test] +fn review_reports_are_canonical_value_free_and_cover_all_contextual_prompts() { + let contract = contract(); + let observation = observed(false); + let mut registry = compile_contract(&contract, &[observation], CompileProfile::Authoring) + .expect("authoring contract compiles"); + let resource = &mut registry.resources[0]; + let person = resource + .properties + .iter_mut() + .find(|property| property.name == "personReference") + .expect("person property"); + person.classification.privacy = "identifying".into(); + person.classification.institutional = "public".into(); + person.classification.handling = registry_relay_v2::contract::Handling::Restricted; + let contact = resource + .properties + .iter_mut() + .find(|property| property.name == "emailPhone") + .expect("contact property"); + contact.classification.privacy = "sensitive-personal".into(); + contact.classification.institutional = "public".into(); + contact.classification.handling = registry_relay_v2::contract::Handling::Confidential; + for property_name in ["regionCode", "categoryCode"] { + resource + .properties + .iter_mut() + .find(|property| property.name == property_name) + .expect("linkable property") + .classification + .privacy = "potentially-linkable".into(); + } + let notes = resource + .properties + .iter_mut() + .find(|property| property.name == "notes") + .expect("notes property"); + notes.transform = Some(CompiledTransform::PartialString { + identifier: "partial-string:suffix:4".into(), + reveal: PartialStringReveal::Suffix, + characters: 4, + }); + notes.classification.handling = registry_relay_v2::contract::Handling::Confidential; + let mut masked_notes = notes.clone(); + masked_notes.name = "maskedNotes".into(); + masked_notes.classification.privacy = "partially-revealed-identifying".into(); + masked_notes.classification.handling = registry_relay_v2::contract::Handling::Internal; + resource.properties.push(masked_notes); + for column in &mut resource.column_accounting { + if matches!(column.column.as_str(), "notes" | "region_code") { + column.classification.handling = registry_relay_v2::contract::Handling::Restricted; + } + } + let operation = &mut resource.operations[0]; + operation.kind = OperationKind::List; + operation.query.selectors.push(CompiledSelector { + name: "region".into(), + source_column: "region_code".into(), + data_type: DataType::String, + minimum_bytes: None, + maximum_bytes: Some(32), + codelist: None, + }); + operation.representations[0].disclosure_handling = + registry_relay_v2::contract::Handling::Confidential; + operation.representations[0].processing_handling = + registry_relay_v2::contract::Handling::Restricted; + + let inventory_digest = classification_inventory_digest(®istry).expect("inventory digest"); + assert_eq!( + classification_inventory_report(®istry, &digest('a')), + Err(IdentificationError::InventoryDigestInvalid) + ); + let inventory = + classification_inventory_report(®istry, &inventory_digest).expect("inventory"); + let representations = + representation_report(®istry, &inventory_digest).expect("representations"); + let findings = contextual_review_findings(®istry, &inventory_digest).expect("findings"); + assert_eq!(inventory.classification_inventory_digest, inventory_digest); + assert_eq!( + representations.classification_inventory_digest, + inventory_digest + ); + assert_eq!(findings.classification_inventory_digest, inventory_digest); + assert_eq!(inventory.resources[0].source_columns.len(), 9); + assert_eq!(inventory.resources[0].properties.len(), 6); + let boundary = &representations.resources[0].operations[0].representations[0]; + assert!(boundary + .processed_source_columns + .contains(&"region_code".into())); + assert!(boundary.disclosed_properties.contains(&"notes".into())); + let codes = findings + .findings + .iter() + .map(|finding| finding.code.as_str()) + .collect::>(); + for expected in [ + "classification.context.identifying_and_sensitive", + "classification.context.potentially_linkable_combination", + "classification.context.personal_institutionally_public", + "classification.context.selector_more_restrictive_than_disclosure", + "classification.context.transform_weaker_than_source", + "classification.context.nonpublic_list_disclosure", + "classification.context.public_processes_hidden_nonpublic", + "classification.context.source_column_incompatible_properties", + ] { + assert!(codes.contains(expected), "missing finding {expected}"); + } + + assert_eq!( + render_classification_inventory_report(&inventory).expect("inventory bytes"), + render_classification_inventory_report(&inventory).expect("inventory bytes again") + ); + assert_eq!( + render_representation_report(&representations).expect("representation bytes"), + render_representation_report(&representations).expect("representation bytes again") + ); + let finding_bytes = render_contextual_review_findings(&findings).expect("finding bytes"); + assert_eq!( + finding_bytes, + render_contextual_review_findings(&findings).expect("finding bytes again") + ); + assert!(!String::from_utf8(finding_bytes) + .expect("JSON") + .contains("ROW_VALUE_CANARY")); +} + +fn assert_candidate( + report: ®istry_relay_v2::identification::IdentificationReport, + column: &str, + confidence: CategoricalConfidence, + status: IdentificationStatus, + role: Option, +) { + let candidate = report + .candidates + .iter() + .find(|candidate| candidate.source_column == column) + .unwrap_or_else(|| panic!("missing candidate for {column}")); + assert_eq!(candidate.confidence, confidence, "{column}"); + assert_eq!(candidate.status, status, "{column}"); + assert_eq!(candidate.suggested_role, role, "{column}"); +} + +fn assert_report_has_no_row_payload_fields(value: &serde_json::Value) { + match value { + serde_json::Value::Object(object) => { + for row_derived_field in [ + "sample", + "samples", + "sampleValue", + "sampleValues", + "row", + "rows", + "value", + "values", + ] { + assert!( + !object.contains_key(row_derived_field), + "report exposes row-derived field {row_derived_field}" + ); + } + object + .values() + .for_each(assert_report_has_no_row_payload_fields); + } + serde_json::Value::Array(values) => { + values + .iter() + .for_each(assert_report_has_no_row_payload_fields); + } + _ => {} + } +} + +fn reviewed_generated( + contract: &RegistryContract, + report: ®istry_relay_v2::identification::IdentificationReport, + inventory_digest: String, +) -> ClassificationReviewDocument { + let mut review = + classification_review_starter(contract, &inventory_digest, report).expect("starter builds"); + review.review_date = "2026-08-10".into(); + review.status = ReviewStatus::Reviewed; + review.rationale_ref = "governance/classification-rationale.yaml".into(); + review +} + +fn digest(character: char) -> String { + format!("sha256:{}", character.to_string().repeat(64)) +} + +fn observed(reverse: bool) -> ObservedSourceSchema { + let mut columns = [ + ("id", "TEXT", false, true), + ("revision", "INTEGER", false, false), + ("status", "TEXT", false, false), + ("recorded_at", "DATETIME", false, false), + ("region_code", "TEXT", true, false), + ("category_code", "TEXT", true, false), + ("person_reference", "TEXT", true, false), + ("email_phone", "TEXT", true, false), + ("notes", "TEXT", true, false), + ] + .into_iter() + .map( + |(name, declared_type, nullable, primary_key)| ObservedColumn { + name: name.into(), + declared_type: declared_type.into(), + nullable, + primary_key, + }, + ) + .collect::>(); + if reverse { + columns.reverse(); + } + ObservedSourceSchema { + source: "registry".into(), + fingerprint: digest('e'), + views: vec![ObservedView { + name: "records".into(), + columns, + }], + } +} + +fn contract() -> RegistryContract { + RegistryContract::parse_yaml( + r#"apiVersion: relay.registrystack.org/v2alpha1 +kind: RegistryContract +metadata: {id: test, version: "1", title: Test} +registry: + registryIdentifier: urn:example:registry:test + name: Test + authority: {identifier: urn:example:authority, name: Authority} + authoritativeScope: Test records + baseUri: https://registry.example.invalid/ + identifierLifecyclePolicyRef: governance/lifecycle.yaml + alignmentTargets: [{name: test-profile, version: "1", status: directional}] +governance: {controller: urn:example:authority, publisher: urn:example:authority, auditOwner: urn:example:audit} +semantics: {localVocabulary: https://registry.example.invalid/vocabulary/} +classifications: + privacy: {scheme: urn:example:privacy, version: "1"} + institutional: {scheme: urn:example:institutional, version: "1"} + handling: {scheme: urn:example:handling, version: "1"} + provenanceRef: governance/classification-review.yaml +sources: + registry: {kind: sqlite, profile: snapshot, expectedSchemaFingerprint: "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"} +resources: + - id: records + title: Records + description: Records + semanticClass: local:Record + source: {source: registry, view: records} + classificationDefaults: {privacy: unknown, institutional: public, handling: public, status: suggested} + recordContext: + recordIdentifier: {sourceColumn: id} + revisionIdentifier: {sourceColumn: revision} + lifecycleState: {sourceColumn: status, codelist: codelists/status.yaml} + recordedAt: {sourceColumn: recorded_at} + sourceColumnClassifications: {} + properties: + regionCode: {label: Region code, description: Region code, sourceColumn: region_code, type: string, sourceRequired: false, semanticTerm: "local:regionCode"} + categoryCode: {label: Category code, description: Category code, sourceColumn: category_code, type: controlled-code, codelist: codelists/category.yaml, sourceRequired: false, semanticTerm: "local:categoryCode"} + personReference: {label: Person reference, description: Person reference, sourceColumn: person_reference, type: string, sourceRequired: false, semanticTerm: "local:personReference"} + emailPhone: {label: Contact, description: Contact, sourceColumn: email_phone, type: string, sourceRequired: false, semanticTerm: "local:contact"} + notes: {label: Notes, description: Notes, sourceColumn: notes, type: string, sourceRequired: false, semanticTerm: "local:notes"} + disclosureProfiles: {default: {properties: [regionCode, categoryCode, personReference, emailPhone, notes]}} + operations: + read: + defaultRepresentation: default + representations: + default: {access: public, disclosureProfile: default} + processingDescriptions: [] +metadataVisibility: {service: public, resources: public, semantics: public, classifications: operator-only, processing: operation-bound} +"#, + ) + .expect("test contract parses") +} diff --git a/crates/registry-relay-v2/tests/multi_resource_isolation.rs b/crates/registry-relay-v2/tests/multi_resource_isolation.rs index 33de94e93..8bf31565e 100644 --- a/crates/registry-relay-v2/tests/multi_resource_isolation.rs +++ b/crates/registry-relay-v2/tests/multi_resource_isolation.rs @@ -20,7 +20,10 @@ use registry_platform_testing::{ use registry_relay_v2::artifacts::{generate_artifacts, ArtifactSet}; use registry_relay_v2::audit::RelayAudit; use registry_relay_v2::auth::RelayAuthenticator; -use registry_relay_v2::compiler::{compile_contract_with_governed_files, GovernedFileSet}; +use registry_relay_v2::compiler::{ + classification_inventory_digest, compile_contract, compile_contract_with_governed_files, + GovernedFileSet, +}; use registry_relay_v2::contract::{RegistryContract, Visibility}; use registry_relay_v2::model::{ CompileProfile, CompiledAccess, CompiledRegistry, ObservedColumn, ObservedSourceSchema, @@ -142,13 +145,17 @@ resources: public-view: {properties: [publicLabel]} operations: list: - access: public - disclosureProfile: public-view + defaultRepresentation: public + representations: + public: {access: public, disclosureProfile: public-view} filters: [] allowUnfiltered: true orderBy: [publicIdentifier] pagination: {defaultPageSize: 1, maximumPageSize: 1} - read: {access: public, disclosureProfile: public-view} + read: + defaultRepresentation: public + representations: + public: {access: public, disclosureProfile: public-view} processingDescriptions: [] - id: protected-unit title: Protected unit @@ -183,32 +190,41 @@ resources: protected-view: {properties: [protectedLabel]} operations: list: - access: - scope: relay:protected:list - purpose: {claim: purpose, allowed: [bounded-read]} - authorityRowBinding: {claim: authority, sourceColumn: authority_key} - disclosureProfile: protected-view + defaultRepresentation: protected + representations: + protected: + access: + scope: relay:protected:list + purpose: {claim: purpose, allowed: [bounded-read]} + authorityRowBinding: {claim: authority, sourceColumn: authority_key} + disclosureProfile: protected-view filters: [] allowUnfiltered: true orderBy: [protectedIdentifier] pagination: {defaultPageSize: 2, maximumPageSize: 2} read: - access: - scope: relay:protected:read - purpose: {claim: purpose, allowed: [bounded-read]} - authorityRowBinding: {claim: authority, sourceColumn: authority_key} - disclosureProfile: protected-view + defaultRepresentation: protected + representations: + protected: + access: + scope: relay:protected:read + purpose: {claim: purpose, allowed: [bounded-read]} + authorityRowBinding: {claim: authority, sourceColumn: authority_key} + disclosureProfile: protected-view lookups: - id: by-key - access: - scope: relay:protected:lookup - purpose: {claim: purpose, allowed: [bounded-read]} - authorityRowBinding: {claim: authority, sourceColumn: authority_key} requestBody: maximumBytes: 128 selectors: lookupKey: {sourceColumn: lookup_key, type: string, minimumBytes: 1, maximumBytes: 32} - disclosureProfile: protected-view + defaultRepresentation: protected + representations: + protected: + access: + scope: relay:protected:lookup + purpose: {claim: purpose, allowed: [bounded-read]} + authorityRowBinding: {claim: authority, sourceColumn: authority_key} + disclosureProfile: protected-view processingDescriptions: - id: protected-consultation operationRefs: [list, read, lookup:by-key] @@ -315,8 +331,13 @@ fn compiler_keeps_every_multi_resource_operation_boundary_local() { assert_eq!(list.identifier, format!("{resource_id}.list")); assert_eq!(list.query.source, SOURCE_ID); assert_eq!(list.query.view, view); - assert_eq!(list.disclosure_profile, disclosure); - assert_eq!(list.selectable_properties, [field]); + let representation = list + .representations + .iter() + .find(|representation| representation.id == list.default_representation) + .expect("default representation is compiled"); + assert_eq!(representation.disclosure_profile, disclosure); + assert_eq!(representation.selectable_properties, [field]); assert_eq!( list.query .pagination @@ -325,7 +346,7 @@ fn compiler_keeps_every_multi_resource_operation_boundary_local() { .maximum_page_size, page_maximum ); - match (&list.access, scope, row_column) { + match (&representation.access, scope, row_column) { (CompiledAccess::Public, None, None) => {} ( CompiledAccess::Protected { @@ -344,8 +365,10 @@ fn compiler_keeps_every_multi_resource_operation_boundary_local() { } boundary => panic!("unexpected compiled access boundary: {boundary:?}"), } - assert!(list.schema_reference.contains(resource_id)); - assert!(list.semantic_model_reference.contains(resource_id)); + assert!(representation.schema_reference.contains(resource_id)); + assert!(representation + .semantic_model_reference + .contains(resource_id)); } let protected = resource(&fixture.compiled, PROTECTED_RESOURCE); @@ -829,6 +852,13 @@ fn compile_fixture() -> Fixture { }) .collect(), }]; + let inventory = compile_contract(&contract, &observed, CompileProfile::Production) + .expect("classification inventory compiles"); + let inventory_digest = + classification_inventory_digest(&inventory).expect("classification inventory digests"); + let review = format!( + "apiVersion: relay.registrystack.org/classification-review/v1\nkind: ClassificationReview\nregistryIdentifier: {REGISTRY_ID}\nclassificationInventoryDigest: {inventory_digest}\nmethod: manual\nreviewer: urn:example:institution:unit-authority\nreviewDate: 2026-08-10\nstatus: reviewed\nrationaleRef: governance/classification-review-rationale.md\n" + ); let governed = GovernedFileSet::from([ ( "governance/identifier-lifecycle.yaml".into(), @@ -836,7 +866,11 @@ fn compile_fixture() -> Fixture { ), ( "governance/classification-provenance.yaml".into(), - b"kind: synthetic-provenance\n".to_vec(), + review.into_bytes(), + ), + ( + "governance/classification-review-rationale.md".into(), + b"Synthetic multi-resource classification review.\n".to_vec(), ), ( "codelists/lifecycle.yaml".into(), @@ -1000,10 +1034,7 @@ async fn send( .await .expect("response reads"); let document = serde_json::from_slice(&bytes).unwrap_or_else(|error| { - panic!( - "response is JSON ({status}): {error}; body={}", - String::from_utf8_lossy(&bytes) - ) + panic!("response is JSON ({status}): {error}; response body withheld") }); (status, document) } @@ -1039,7 +1070,7 @@ async fn send_raw_body( } fn assert_problem(response: (StatusCode, Value), status: StatusCode, code: &str) { - assert_eq!(response.0, status, "problem body={}", response.1); + assert_eq!(response.0, status, "problem response body withheld"); assert_eq!(response.1["code"], code); let text = response.1.to_string(); for absent in ["PUBLIC-CANARY", "PROTECTED-CANARY", "lookup-a1", "zone-a"] { @@ -1048,7 +1079,7 @@ fn assert_problem(response: (StatusCode, Value), status: StatusCode, code: &str) } fn assert_success(response: (StatusCode, Value)) -> Value { - assert_eq!(response.0, StatusCode::OK, "response body={}", response.1); + assert_eq!(response.0, StatusCode::OK, "response body withheld"); response.1 } diff --git a/crates/registry-relay-v2/tests/process_http.rs b/crates/registry-relay-v2/tests/process_http.rs index 59fd96b9f..39849e8f3 100644 --- a/crates/registry-relay-v2/tests/process_http.rs +++ b/crates/registry-relay-v2/tests/process_http.rs @@ -10,8 +10,15 @@ use std::path::Path; use std::process::{Child, Command, Stdio}; use std::time::{Duration, Instant}; -use registry_platform_sqlite::materialize_fixture; -use registry_relay_v2::contract::RelayRuntime; +use registry_platform_sqlite::{ + inspect_schema, materialize_fixture, CapturedSnapshot, DatabaseProfile, InspectionLimits, + SchemaObjectKind, +}; +use registry_relay_v2::compiler::{classification_inventory_digest, compile_contract}; +use registry_relay_v2::contract::{ClassificationReviewDocument, RegistryContract, RelayRuntime}; +use registry_relay_v2::model::{ + CompileProfile, ObservedColumn, ObservedSourceSchema, ObservedView, +}; use registry_relay_v2::tooling::{package_project, PackageOptions}; use reqwest::{Client, StatusCode}; use serde_json::Value; @@ -114,6 +121,7 @@ async fn built_relay_serves_a_sealed_package_over_real_tcp_and_shuts_down() { &fs::read_to_string(etc.join("fixture.sql")).expect("fixture SQL reads"), ) .expect("fixture materializes"); + make_business_project_public_only(&etc, &source); let package = data.join("business-registry-package"); let runtime_path = etc.join("runtime.yaml"); @@ -126,6 +134,7 @@ async fn built_relay_serves_a_sealed_package_over_real_tcp_and_shuts_down() { drop(reservation); runtime.server.bind = address.to_string(); runtime.package_path = package.to_string_lossy().into_owned(); + runtime.authentication.issuer = None; let mut runtime_value = serde_json::to_value(&runtime).expect("runtime becomes a value"); *runtime_value .pointer_mut("/sources/companies/path") @@ -173,6 +182,89 @@ async fn built_relay_serves_a_sealed_package_over_real_tcp_and_shuts_down() { ); } +fn make_business_project_public_only(project: &Path, source: &Path) { + let contract_path = project.join("registry.yaml"); + let mut value: Value = serde_norway::from_str( + &fs::read_to_string(&contract_path).expect("business contract reads"), + ) + .expect("business contract becomes a value"); + for pointer in [ + "/resources/0/operations/list/representations", + "/resources/0/operations/read/representations", + ] { + value + .pointer_mut(pointer) + .and_then(Value::as_object_mut) + .expect("business representation map") + .remove("registrar") + .expect("registrar representation exists"); + } + fs::write( + &contract_path, + serde_norway::to_string(&value).expect("public-only contract serializes"), + ) + .expect("public-only contract writes"); + let contract = RegistryContract::parse_yaml( + &fs::read_to_string(&contract_path).expect("public-only contract reads"), + ) + .expect("public-only contract parses"); + + let captured = CapturedSnapshot::capture(source).expect("business snapshot captures"); + let catalog = inspect_schema( + &DatabaseProfile::Snapshot(captured), + &InspectionLimits { + maximum_objects: 10_000, + maximum_sql_bytes: 8 * 1024 * 1024, + maximum_statement_steps: 1_000_000, + timeout: Duration::from_secs(5), + }, + ) + .expect("business schema inspects"); + let source_id = contract + .sources + .keys() + .next() + .expect("one source") + .to_owned(); + let observed = vec![ObservedSourceSchema { + source: source_id, + fingerprint: catalog.fingerprint, + views: catalog + .objects + .into_iter() + .filter(|object| object.kind == SchemaObjectKind::View) + .map(|object| ObservedView { + name: object.name, + columns: object + .columns + .into_iter() + .map(|column| ObservedColumn { + name: column.name, + declared_type: column.declared_type, + nullable: column.nullable, + primary_key: column.primary_key, + }) + .collect(), + }) + .collect(), + }]; + let compiled = compile_contract(&contract, &observed, CompileProfile::Production) + .expect("public-only inventory compiles"); + let inventory_digest = + classification_inventory_digest(&compiled).expect("public-only inventory digests"); + let review_path = project.join(&contract.classifications.provenance_ref); + let mut review: ClassificationReviewDocument = serde_norway::from_str( + &fs::read_to_string(&review_path).expect("classification review reads"), + ) + .expect("classification review parses"); + review.classification_inventory_digest = inventory_digest; + fs::write( + review_path, + serde_norway::to_string(&review).expect("classification review serializes"), + ) + .expect("classification review writes"); +} + async fn serve_one_lifecycle(runtime: &Path, client: &Client, base: &str) -> Vec { let mut process = RelayProcess::spawn(runtime); wait_until_ready(client, base, &mut process).await; diff --git a/crates/registry-relay-v2/tests/representation_http.rs b/crates/registry-relay-v2/tests/representation_http.rs new file mode 100644 index 000000000..346f04824 --- /dev/null +++ b/crates/registry-relay-v2/tests/representation_http.rs @@ -0,0 +1,1203 @@ +// SPDX-License-Identifier: Apache-2.0 + +use std::collections::BTreeMap; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use axum::body::{to_bytes, Body}; +use http::header::{AUTHORIZATION, CACHE_CONTROL, CONTENT_TYPE, ETAG, IF_NONE_MATCH}; +use http::{Method, Request, StatusCode}; +use registry_platform_audit::{AuditChainHasher, AuditEnvelope, AuditError, AuditSink, ChainState}; +use registry_platform_httputil::FetchUrlPolicy; +use registry_platform_oidc::{JwksFetcher, JwksFetcherConfig, TokenVerifier}; +use registry_platform_sqlite::{ + inspect_schema, materialize_fixture, CapturedSnapshot, DatabaseProfile, InspectionLimits, +}; +use registry_platform_testing::{ + fixtures, oidc_verifier_config, sign_ed25519_compact_jwt, MockIdp, +}; +use registry_relay_v2::artifacts::{generate_artifacts, ArtifactSet}; +use registry_relay_v2::audit::RelayAudit; +use registry_relay_v2::auth::RelayAuthenticator; +use registry_relay_v2::contract::{ + DataType, DateInputType, DatePrecision, Handling, PartialStringReveal, ReviewStatus, + SourceProfile, Visibility, +}; +use registry_relay_v2::cursor::CursorKey; +use registry_relay_v2::model::{ + CapabilityFamily, CompiledAccess, CompiledCodelist, CompiledDisclosureProfile, + CompiledMetadataVisibility, CompiledOperation, CompiledPagination, CompiledProperty, + CompiledPurpose, CompiledRecordContext, CompiledRegistry, CompiledRepresentation, + CompiledResource, CompiledRowBinding, CompiledSelector, CompiledSource, CompiledTransform, + ConsultationPattern, EffectiveClassification, OperationKind, QueryPlan, RowAuthoritySource, +}; +use registry_relay_v2::server::{ + router, InstitutionMetadata, QuotaConfig, RelayService, ServiceMetadata, +}; +use registry_relay_v2::sqlite_runtime::{RuntimeSourceBinding, SqliteRuntime, SqliteRuntimeLimits}; +use serde_json::{json, Value}; +use tempfile::TempDir; +use tower::ServiceExt as _; + +const SOURCE: &str = "source"; +const RESOURCE: &str = "record"; +const AUDIENCE: &str = "urn:example:relay:representations"; + +const FIXTURE_SQL: &str = r#" +CREATE TABLE source_records ( + record_id TEXT PRIMARY KEY NOT NULL, + revision TEXT NOT NULL, + lifecycle TEXT NOT NULL, + recorded_at TEXT NOT NULL, + public_name TEXT NOT NULL, + prederived_mask TEXT NOT NULL, + secret_value ANY, + event_date ANY NOT NULL, + optional_value TEXT, + lookup_key TEXT NOT NULL, + authority TEXT NOT NULL +) STRICT; + +INSERT INTO source_records VALUES +('record-1', '1', 'ACTIVE', '2026-08-01T00:00:00Z', 'Public one', 'PRE-1', 'ABCD', '2026-08-10', NULL, 'lookup-1', 'area-a'), +('record-1a', '1', 'ACTIVE', 'not-a-core-date', 'Public invalid core', 'PRE-CORE', 'CORE', '2026-08-10', NULL, 'lookup-core', 'area-a'), +('record-2', '1', 'ACTIVE', '2026-08-02T00:00:00Z', 'Public two', 'PRE-2', 'ABCDEF', '2026-09-11', 'OPTIONAL-9', 'lookup-2', 'area-a'), +('record-bad-date', '1', 'ACTIVE', '2026-08-03T00:00:00Z', 'Public bad date', 'PRE-3', 'ABCDEFGH', 'not-a-date', NULL, 'lookup-3', 'area-a'), +('record-null', '1', 'ACTIVE', '2026-08-04T00:00:00Z', 'Public null', 'PRE-4', NULL, '2026-10-12', NULL, 'lookup-4', 'area-a'), +('record-wrong-secret-type', '1', 'ACTIVE', '2026-08-05T00:00:00Z', 'Public wrong secret type', 'PRE-5', 42, '2026-10-12', NULL, 'lookup-5', 'area-a'), +('record-wrong-date-type', '1', 'ACTIVE', '2026-08-06T00:00:00Z', 'Public wrong date type', 'PRE-6', 'ABCDEFGH', 42, NULL, 'lookup-6', 'area-a'), +('record-overlong-secret', '1', 'ACTIVE', '2026-08-07T00:00:00Z', 'Public overlong secret', 'PRE-7', replace(hex(zeroblob(2050)), '0', 'A'), '2026-10-12', NULL, 'lookup-7', 'area-a'), +('record-overlong-date', '1', 'ACTIVE', '2026-08-08T00:00:00Z', 'Public overlong date', 'PRE-8', 'ABCDEFGH', replace(hex(zeroblob(2050)), '0', 'A'), NULL, 'lookup-8', 'area-a'); + +CREATE VIEW relay_records AS +SELECT record_id, revision, lifecycle, recorded_at, public_name, prederived_mask, + secret_value, event_date, optional_value, lookup_key, authority +FROM source_records; +"#; + +#[derive(Default)] +struct RecordingSink { + records: Mutex>, + fail_after: Option, +} + +impl RecordingSink { + fn failing_after(successes: usize) -> Self { + Self { + records: Mutex::new(Vec::new()), + fail_after: Some(successes), + } + } + + fn values(&self) -> Vec { + self.records + .lock() + .expect("audit lock") + .iter() + .map(|envelope| envelope.record.clone()) + .collect() + } +} + +#[async_trait::async_trait] +impl AuditSink for RecordingSink { + async fn write(&self, envelope: &AuditEnvelope) -> Result<(), AuditError> { + let mut records = self.records.lock().expect("audit lock"); + if self + .fail_after + .is_some_and(|maximum| records.len() >= maximum) + { + return Err(AuditError::Io(std::io::Error::other( + "controlled audit failure", + ))); + } + records.push(envelope.clone()); + Ok(()) + } + + #[allow(deprecated)] + async fn tail_hash(&self) -> Result, AuditError> { + Ok(self + .records + .lock() + .expect("audit lock") + .last() + .map(|envelope| envelope.record_hash)) + } + + async fn tail_hash_with_hasher( + &self, + _hasher: &AuditChainHasher, + ) -> Result, AuditError> { + Ok(self + .records + .lock() + .expect("audit lock") + .last() + .map(|envelope| envelope.record_hash)) + } +} + +struct Harness { + app: axum::Router, + database: std::path::PathBuf, + artifacts: Arc, + idp: MockIdp, + _temp: TempDir, +} + +impl Harness { + async fn open(quota: Option, sink: Arc) -> Self { + let temp = tempfile::tempdir().expect("temporary fixture"); + let database = temp.path().join("fixture.sqlite"); + materialize_fixture(&database, FIXTURE_SQL).expect("fixture materializes"); + let captured = CapturedSnapshot::capture(&database).expect("fixture captures"); + let fingerprint = inspect_schema( + &DatabaseProfile::Snapshot(captured), + &InspectionLimits { + maximum_objects: 100, + maximum_sql_bytes: 1024 * 1024, + maximum_statement_steps: 100_000, + timeout: Duration::from_secs(2), + }, + ) + .expect("fixture schema inspects") + .fingerprint; + let registry = Arc::new(compiled_registry(fingerprint)); + let artifacts = Arc::new(generate_artifacts(®istry).expect("artifacts generate")); + let sqlite = Arc::new( + SqliteRuntime::open( + ®istry, + &BTreeMap::from([( + SOURCE.to_owned(), + RuntimeSourceBinding { + path: database.clone(), + }, + )]), + SqliteRuntimeLimits { + request_timeout: Duration::from_secs(2), + concurrent_queries: 4, + }, + ) + .expect("SQLite runtime opens"), + ); + let idp = MockIdp::start().await; + let fetcher = Arc::new(JwksFetcher::new_with_fetch_url_policy( + idp.jwks_uri(), + JwksFetcherConfig::defaults(), + FetchUrlPolicy::dev(), + )); + fetcher.ensure_key_set().await.expect("fixture JWKS loads"); + let mut verifier = oidc_verifier_config(idp.issuer(), vec![AUDIENCE.into()]); + verifier.allowed_typ = vec!["at+jwt".into()]; + verifier.max_token_lifetime = Some(Duration::from_secs(3600)); + let authenticator = RelayAuthenticator::new( + Arc::new(TokenVerifier::new(verifier, fetcher)), + AUDIENCE.into(), + Duration::from_secs(30), + ); + let sink_object: Arc = sink.clone(); + let chain = Arc::new( + ChainState::bootstrap_unkeyed_dev_only(sink_object.as_ref()) + .await + .expect("audit chain starts"), + ); + let service = Arc::new(RelayService::new( + registry, + Arc::clone(&artifacts), + sqlite, + Some(authenticator), + RelayAudit::new(chain, sink_object), + Some(Arc::new( + CursorKey::new(vec![0x5a; 32]).expect("cursor key"), + )), + Duration::from_secs(300), + Duration::from_secs(2), + quota, + ServiceMetadata { + authority: InstitutionMetadata { + identifier: "urn:example:authority".into(), + name: "Example Authority".into(), + }, + operator: None, + authoritative_scope: "Synthetic representation tests".into(), + alignment_targets: Vec::new(), + }, + )); + Self { + app: router(service), + database, + artifacts, + idp, + _temp: temp, + } + } + + fn token(&self, scopes: &[&str], purpose: &str, authority: &str) -> String { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_secs(); + sign_ed25519_compact_jwt( + fixtures::ED25519_PRIVATE_JWK, + "at+jwt", + "registry-platform-testing-ed25519-1", + json!({ + "iss": self.idp.issuer(), + "aud": AUDIENCE, + "sub": "synthetic-client", + "scope": scopes.join(" "), + "purpose": purpose, + "authority": authority, + "iat": now, + "nbf": now, + "exp": now + 900, + "jti": format!("fixture-{now}-{}", scopes.join("-")), + }), + ) + } + + async fn send( + &self, + method: Method, + uri: &str, + token: Option<&str>, + body: Option, + headers: &[(&str, &str)], + ) -> (StatusCode, http::HeaderMap, Vec) { + let has_body = body.is_some(); + let bytes = body + .map(|value| serde_json::to_vec(&value).expect("body serializes")) + .unwrap_or_default(); + let mut request = Request::builder() + .method(method) + .uri(uri) + .body(Body::from(bytes)) + .expect("request builds"); + if has_body { + request + .headers_mut() + .insert(CONTENT_TYPE, "application/json".parse().expect("header")); + } + if let Some(token) = token { + request.headers_mut().insert( + AUTHORIZATION, + format!("Bearer {token}").parse().expect("bearer header"), + ); + } + for (name, value) in headers { + request.headers_mut().insert( + name.parse::().expect("header name"), + value.parse().expect("header value"), + ); + } + let response = self + .app + .clone() + .oneshot(request) + .await + .expect("router responds"); + let status = response.status(); + let headers = response.headers().clone(); + let body = to_bytes(response.into_body(), 8 * 1024 * 1024) + .await + .expect("response body reads") + .to_vec(); + (status, headers, body) + } +} + +#[tokio::test] +async fn representation_selection_authenticates_then_authorizes_the_exact_profile() { + let sink = Arc::new(RecordingSink::default()); + let harness = Harness::open(None, Arc::clone(&sink)).await; + let limited = harness.token(&["registry:limited"], "review", "area-a"); + + let (status, _, body) = harness + .send( + Method::GET, + "/v2/resources/record/records/record-1?representation=missing", + Some("not-a-jwt"), + None, + &[], + ) + .await; + assert_problem( + status, + &body, + StatusCode::UNAUTHORIZED, + "auth.invalid_credential", + ); + + for (token, representation, expected_status, expected_code) in [ + ( + None, + "caseworker", + StatusCode::UNAUTHORIZED, + "auth.missing_credential", + ), + ( + None, + "missing", + StatusCode::NOT_FOUND, + "representation.not_found", + ), + ( + Some(limited.as_str()), + "caseworker", + StatusCode::FORBIDDEN, + "consultation.denied", + ), + ( + Some(limited.as_str()), + "missing", + StatusCode::NOT_FOUND, + "representation.not_found", + ), + ] { + let uri = format!("/v2/resources/record/records/record-1?representation={representation}"); + let (status, _, body) = harness.send(Method::GET, &uri, token, None, &[]).await; + assert_problem(status, &body, expected_status, expected_code); + } + + let records = sink.values(); + assert!(records.iter().all(|event| event["phase"] == "refusal")); + assert_eq!( + records + .iter() + .filter(|event| event["representation"] == "caseworker") + .count(), + 2 + ); + assert_eq!( + records + .iter() + .filter(|event| event.get("representation").is_none()) + .count(), + 3 + ); + let audit_wire = serde_json::to_string(&records).expect("audit serializes"); + assert!(!audit_wire.contains("not-a-jwt")); +} + +#[tokio::test] +async fn preflight_refusals_do_not_reach_source_and_attempt_audit_precedes_source_access() { + let sink = Arc::new(RecordingSink::default()); + let harness = Harness::open(None, Arc::clone(&sink)).await; + let limited = harness.token(&["registry:limited"], "review", "area-a"); + + std::fs::rename(&harness.database, harness.database.with_extension("moved")) + .expect("test source moves after runtime open"); + for (uri, expected_status, code) in [ + ( + "/v2/resources/record/records?representation=", + StatusCode::BAD_REQUEST, + "request.representation_invalid", + ), + ( + "/v2/resources/record/records?representation=limited&representation=caseworker", + StatusCode::BAD_REQUEST, + "request.representation_invalid", + ), + ( + "/v2/resources/record/records?representation=missing", + StatusCode::NOT_FOUND, + "representation.not_found", + ), + ( + "/v2/resources/record/records?representation=limited&fields=secretValue", + StatusCode::BAD_REQUEST, + "request.fields_invalid", + ), + ] { + let (status, _, body) = harness + .send(Method::GET, uri, Some(&limited), None, &[]) + .await; + assert_problem(status, &body, expected_status, code); + } + + assert_eq!( + sink.values() + .iter() + .filter(|event| event["phase"] == "attempt") + .count(), + 0 + ); + + let (status, _, body) = harness + .send( + Method::GET, + "/v2/resources/record/records/record-1?representation=limited", + Some(&limited), + None, + &[], + ) + .await; + assert_problem( + status, + &body, + StatusCode::SERVICE_UNAVAILABLE, + "source.unavailable", + ); + let correlated = sink + .values() + .into_iter() + .filter(|event| event["operationIdentifier"] == "record.read") + .collect::>(); + assert_eq!(correlated.len(), 2); + assert_eq!(correlated[0]["phase"], "attempt"); + assert_eq!(correlated[1]["phase"], "terminal"); + assert_eq!(correlated[1]["outcome"], "source-failed"); +} + +#[tokio::test] +async fn fields_only_minimize_the_selected_representation() { + let harness = Harness::open(None, Arc::new(RecordingSink::default())).await; + let limited = harness.token(&["registry:limited"], "review", "area-a"); + let (status, _, body) = harness + .send( + Method::POST, + "/v2/resources/record/lookups/by-key?representation=limited&fields=maskedSecret", + Some(&limited), + Some(json!({"selectors": {"lookupKey": "lookup-2"}})), + &[], + ) + .await; + assert_eq!(status, StatusCode::OK); + let document: Value = serde_json::from_slice(&body).expect("JSON response"); + assert_eq!( + document["data"]["domainData"], + json!({"maskedSecret": "***CDEF"}) + ); + assert_eq!(document["meta"]["representation"], "limited"); + assert_eq!(document["meta"]["selectedFields"], json!(["maskedSecret"])); + + let (status, _, body) = harness + .send( + Method::GET, + "/v2/resources/record/records/record-1?representation=limited&fields=secretValue", + Some(&limited), + None, + &[], + ) + .await; + assert_problem( + status, + &body, + StatusCode::BAD_REQUEST, + "request.fields_invalid", + ); +} + +#[tokio::test] +async fn cursor_and_etag_are_bound_to_selected_representation() { + let harness = Harness::open(None, Arc::new(RecordingSink::default())).await; + let all = harness.token( + &["registry:limited", "registry:caseworker"], + "review", + "area-a", + ); + let (status, headers, body) = harness + .send( + Method::GET, + "/v2/resources/record/records/record-1", + None, + None, + &[], + ) + .await; + assert_eq!(status, StatusCode::OK); + let etag = headers + .get(ETAG) + .and_then(|value| value.to_str().ok()) + .expect("public profile has ETag") + .to_owned(); + let (status, headers, _) = harness + .send( + Method::GET, + "/v2/resources/record/records/record-1?representation=public", + None, + None, + &[(IF_NONE_MATCH.as_str(), &etag)], + ) + .await; + assert_eq!(status, StatusCode::NOT_MODIFIED); + assert_eq!( + headers.get(ETAG).and_then(|value| value.to_str().ok()), + Some(etag.as_str()) + ); + assert!(!body.is_empty()); + + let (status, headers, body) = harness + .send( + Method::GET, + "/v2/resources/record/records?representation=limited&pageSize=1", + Some(&all), + None, + &[], + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!( + headers + .get(CACHE_CONTROL) + .and_then(|value| value.to_str().ok()), + Some("no-store") + ); + assert!(headers.get(ETAG).is_none()); + let document: Value = serde_json::from_slice(&body).expect("JSON response"); + let cursor = document["pageInfo"]["nextCursor"] + .as_str() + .expect("limited cursor"); + let uri = format!("/v2/resources/record/records?representation=caseworker&cursor={cursor}"); + let (status, _, body) = harness.send(Method::GET, &uri, Some(&all), None, &[]).await; + assert_problem( + status, + &body, + StatusCode::BAD_REQUEST, + "query.cursor_invalid", + ); +} + +#[tokio::test] +async fn metadata_and_artifacts_authorize_each_representation_exactly() { + let harness = Harness::open(None, Arc::new(RecordingSink::default())).await; + let (status, _, body) = harness.send(Method::GET, "/v2", None, None, &[]).await; + assert_eq!(status, StatusCode::OK); + let text = String::from_utf8(body).expect("metadata is UTF-8"); + assert!(text.contains("public")); + assert!(!text.contains("limited")); + assert!(!text.contains("caseworker")); + + let limited_artifact = harness + .artifacts + .artifacts + .iter() + .find(|artifact| artifact.representation_identifier.as_deref() == Some("limited")) + .expect("limited representation artifact"); + let path = format!("/v2/artifacts/{}", limited_artifact.id); + let caseworker = harness.token(&["registry:caseworker"], "review", "area-a"); + let (status, _, _) = harness + .send(Method::GET, &path, Some(&caseworker), None, &[]) + .await; + assert_eq!(status, StatusCode::NOT_FOUND); + let limited = harness.token(&["registry:limited"], "review", "area-a"); + let (status, _, body) = harness + .send(Method::GET, &path, Some(&limited), None, &[]) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body, limited_artifact.content); +} + +#[tokio::test] +async fn malformed_registry_core_fails_closed_and_list_release_is_atomic() { + let sink = Arc::new(RecordingSink::default()); + let harness = Harness::open(None, Arc::clone(&sink)).await; + + for uri in [ + "/v2/resources/record/records/record-1a", + "/v2/resources/record/records?pageSize=2", + ] { + let (status, _, body) = harness.send(Method::GET, uri, None, None, &[]).await; + assert_problem( + status, + &body, + StatusCode::SERVICE_UNAVAILABLE, + "source.unavailable", + ); + let wire = String::from_utf8(body).expect("problem UTF-8"); + for source_value in [ + "record-1a", + "not-a-core-date", + "Public one", + "Public invalid core", + ] { + assert!(!wire.contains(source_value)); + } + } + + let (status, _, body) = harness + .send( + Method::POST, + "/v2/resources/record/lookups/by-key", + None, + Some(json!({"selectors": {"lookupKey": "lookup-core"}})), + &[], + ) + .await; + assert_problem( + status, + &body, + StatusCode::NOT_FOUND, + "consultation.unresolved", + ); + + let records = sink.values(); + let terminal = records + .iter() + .filter(|record| record["phase"] == "terminal") + .collect::>(); + assert_eq!(terminal.len(), 3); + assert_eq!(terminal[0]["outcome"], "source-failed"); + assert_eq!(terminal[1]["outcome"], "source-failed"); + assert_eq!(terminal[2]["outcome"], "unresolved"); + let audit_wire = serde_json::to_string(&records).expect("audit serializes"); + for source_value in [ + "record-1a", + "not-a-core-date", + "Public one", + "Public invalid core", + "lookup-core", + ] { + assert!(!audit_wire.contains(source_value)); + } +} + +#[tokio::test] +async fn transforms_are_bounded_value_free_and_terminal_audit_gates_exact_bytes() { + let sink = Arc::new(RecordingSink::default()); + let harness = Harness::open(None, Arc::clone(&sink)).await; + let limited = harness.token(&["registry:limited"], "review", "area-a"); + let (status, _, body) = harness + .send( + Method::GET, + "/v2/resources/record/records/record-1?representation=limited", + Some(&limited), + None, + &[], + ) + .await; + assert_eq!(status, StatusCode::OK); + let document: Value = serde_json::from_slice(&body).expect("JSON response"); + assert_eq!(document["data"]["domainData"]["maskedSecret"], "***"); + assert_eq!(document["data"]["domainData"]["eventYear"], "2026"); + assert!(document["data"]["domainData"] + .get("maskedOptional") + .is_none()); + + let records = sink.values(); + let correlated = records + .iter() + .filter(|record| record["operationIdentifier"] == "record.read") + .collect::>(); + assert_eq!(correlated.len(), 2); + assert_eq!(correlated[0]["operationId"], correlated[1]["operationId"]); + for record in &correlated { + assert_eq!(record["representation"], "limited"); + assert_eq!(record["disclosureProfile"], "limited-disclosure"); + assert_eq!(record["processingHandling"], "restricted"); + assert_eq!(record["disclosureHandling"], "confidential"); + assert_eq!( + record["selectedProperties"], + json!(["maskedSecret", "eventYear", "maskedOptional"]) + ); + assert_eq!( + record["transformIdentifiers"], + json!(["date-precision:date:year", "partial-string:suffix:4"]) + ); + } + let audit_wire = serde_json::to_string(&records).expect("audit serializes"); + for canary in ["ABCD", "***", "2026-08-10", "OPTIONAL-9"] { + assert!(!audit_wire.contains(canary), "audit disclosed value canary"); + } + + for record in [ + "record-bad-date", + "record-null", + "record-wrong-secret-type", + "record-wrong-date-type", + "record-overlong-secret", + "record-overlong-date", + ] { + let uri = format!("/v2/resources/record/records/{record}?representation=limited"); + let (status, _, body) = harness + .send(Method::GET, &uri, Some(&limited), None, &[]) + .await; + assert_problem( + status, + &body, + StatusCode::SERVICE_UNAVAILABLE, + "source.unavailable", + ); + let problem = String::from_utf8(body).expect("problem UTF-8"); + assert!(!problem.contains(record)); + assert!(!problem.contains("not-a-date")); + assert!(!problem.contains("AAAAAAAAAAAAAAAA")); + } + + let (status, _, body) = harness + .send( + Method::POST, + "/v2/resources/record/lookups/by-key?representation=limited", + Some(&limited), + Some(json!({"selectors": {"lookupKey": "lookup-3"}})), + &[], + ) + .await; + assert_problem( + status, + &body, + StatusCode::NOT_FOUND, + "consultation.unresolved", + ); + assert!(!String::from_utf8(body) + .expect("problem UTF-8") + .contains("not-a-date")); + + let failing = Harness::open(None, Arc::new(RecordingSink::failing_after(1))).await; + let token = failing.token(&["registry:limited"], "review", "area-a"); + let (status, _, body) = failing + .send( + Method::GET, + "/v2/resources/record/records/record-2?representation=limited", + Some(&token), + None, + &[], + ) + .await; + assert_problem( + status, + &body, + StatusCode::SERVICE_UNAVAILABLE, + "audit.unavailable", + ); + assert!(!String::from_utf8(body) + .expect("problem UTF-8") + .contains("***CDEF")); +} + +#[tokio::test] +async fn quotas_remain_operation_scoped_across_representations() { + let harness = Harness::open( + Some(QuotaConfig { + requests_per_minute: 1, + burst: 1, + }), + Arc::new(RecordingSink::default()), + ) + .await; + let all = harness.token( + &["registry:limited", "registry:caseworker"], + "review", + "area-a", + ); + let (status, _, _) = harness + .send( + Method::GET, + "/v2/resources/record/records/record-1?representation=public", + None, + None, + &[], + ) + .await; + assert_eq!(status, StatusCode::OK); + let (status, _, body) = harness + .send( + Method::GET, + "/v2/resources/record/records/record-1?representation=caseworker", + Some(&all), + None, + &[], + ) + .await; + assert_problem( + status, + &body, + StatusCode::TOO_MANY_REQUESTS, + "consultation.rate_limited", + ); + + let (status, _, body) = harness + .send( + Method::GET, + "/v2/resources/record/records/record-1?representation=public", + None, + None, + &[], + ) + .await; + assert_problem( + status, + &body, + StatusCode::TOO_MANY_REQUESTS, + "consultation.rate_limited", + ); + + let (status, _, body) = harness + .send( + Method::GET, + "/v2/resources/record/records/record-1?representation=caseworker", + Some(&all), + None, + &[], + ) + .await; + assert_problem( + status, + &body, + StatusCode::TOO_MANY_REQUESTS, + "consultation.rate_limited", + ); +} + +fn assert_problem(actual: StatusCode, body: &[u8], expected: StatusCode, code: &str) { + assert_eq!(actual, expected); + let document: Value = serde_json::from_slice(body).expect("problem JSON"); + assert_eq!(document["code"], code); + let wire = String::from_utf8_lossy(body); + for value in [ + "ABCD", + "ABCDEF", + "not-a-date", + "not-a-core-date", + "OPTIONAL-9", + ] { + assert!(!wire.contains(value)); + } +} + +fn compiled_registry(fingerprint: String) -> CompiledRegistry { + let core_columns = ["record_id", "revision", "lifecycle", "recorded_at"]; + let public = representation( + "public", + CompiledAccess::Public, + "public-disclosure", + &["publicName", "prederivedMask"], + &core_columns + .into_iter() + .chain(["public_name", "prederived_mask"]) + .collect::>(), + Handling::Public, + Handling::Public, + &[], + ); + let protected_access = |scope: &str| CompiledAccess::Protected { + scope: scope.into(), + purpose: Some(CompiledPurpose { + claim: "purpose".into(), + allowed: vec!["review".into()], + }), + row_binding: Some(CompiledRowBinding { + source: RowAuthoritySource::Claim("authority".into()), + source_column: "authority".into(), + }), + }; + let limited = representation( + "limited", + protected_access("registry:limited"), + "limited-disclosure", + &["maskedSecret", "eventYear", "maskedOptional"], + &core_columns + .into_iter() + .chain(["secret_value", "event_date", "optional_value"]) + .collect::>(), + Handling::Restricted, + Handling::Confidential, + &[ + "maskedSecret=partial-string:suffix:4", + "eventYear=date-precision:date:year", + "maskedOptional=partial-string:suffix:4", + ], + ); + let caseworker = representation( + "caseworker", + protected_access("registry:caseworker"), + "caseworker-disclosure", + &["secretValue"], + &core_columns + .into_iter() + .chain(["secret_value"]) + .collect::>(), + Handling::Restricted, + Handling::Restricted, + &[], + ); + let representations = vec![public.clone(), limited.clone(), caseworker.clone()]; + let list = CompiledOperation { + identifier: "record.list".into(), + family: CapabilityFamily::Consultation, + pattern: ConsultationPattern::List, + kind: OperationKind::List, + default_representation: "public".into(), + representations: representations.clone(), + query: QueryPlan { + source: SOURCE.into(), + view: "relay_records".into(), + filters: Vec::new(), + selectors: Vec::new(), + order_by: vec!["record_id".into()], + allow_unfiltered: true, + pagination: Some(CompiledPagination { + default_page_size: 2, + maximum_page_size: 4, + }), + maximum_request_body_bytes: None, + }, + }; + let read = CompiledOperation { + identifier: "record.read".into(), + family: CapabilityFamily::Consultation, + pattern: ConsultationPattern::Retrieve, + kind: OperationKind::Read, + default_representation: "public".into(), + representations: representations.clone(), + query: QueryPlan { + source: SOURCE.into(), + view: "relay_records".into(), + filters: Vec::new(), + selectors: Vec::new(), + order_by: Vec::new(), + allow_unfiltered: false, + pagination: None, + maximum_request_body_bytes: None, + }, + }; + let lookup = CompiledOperation { + identifier: "record.lookup.by-key".into(), + family: CapabilityFamily::Consultation, + pattern: ConsultationPattern::Search, + kind: OperationKind::Lookup { + name: "by-key".into(), + }, + default_representation: "public".into(), + representations, + query: QueryPlan { + source: SOURCE.into(), + view: "relay_records".into(), + filters: Vec::new(), + selectors: vec![CompiledSelector { + name: "lookupKey".into(), + source_column: "lookup_key".into(), + data_type: DataType::String, + minimum_bytes: Some(1), + maximum_bytes: Some(32), + codelist: None, + }], + order_by: Vec::new(), + allow_unfiltered: false, + pagination: None, + maximum_request_body_bytes: Some(256), + }, + }; + CompiledRegistry { + contract_revision: "sha256:contract".into(), + contract_id: "representation-tests".into(), + contract_version: "1".into(), + registry_identifier: "urn:example:registry:representations".into(), + registry_name: "Representation test Registry".into(), + authority_identifier: "urn:example:authority".into(), + operator_identifier: None, + authoritative_scope: "Synthetic representation tests".into(), + base_uri: "https://registry.example.invalid/".into(), + identifier_lifecycle_policy_ref: "governance/lifecycle.yaml".into(), + alignment_targets: Vec::new(), + controller_identifier: "urn:example:authority".into(), + publisher_identifier: "urn:example:authority".into(), + audit_owner_identifier: "urn:example:audit".into(), + local_vocabulary: "https://registry.example.invalid/vocabulary/".into(), + semantic_alignments: Vec::new(), + governed_files: Vec::new(), + classification_review: None, + codelists: vec![CompiledCodelist { + path: "codelists/lifecycle.yaml".into(), + id: "lifecycle".into(), + version: "1".into(), + values: vec!["ACTIVE".into()], + }], + sources: vec![CompiledSource { + id: SOURCE.into(), + profile: SourceProfile::Snapshot, + expected_schema_fingerprint: fingerprint, + observed_schema: None, + }], + resources: vec![CompiledResource { + id: RESOURCE.into(), + title: "Record".into(), + description: "Synthetic record".into(), + semantic_class: "https://registry.example.invalid/vocabulary/Record".into(), + source: SOURCE.into(), + view: "relay_records".into(), + record_context: CompiledRecordContext { + record_identifier_column: "record_id".into(), + revision_identifier_column: "revision".into(), + lifecycle_state_column: "lifecycle".into(), + lifecycle_state_codelist: "codelists/lifecycle.yaml".into(), + recorded_at_column: "recorded_at".into(), + schema_reference: "https://registry.example.invalid/artifacts/full-schema".into(), + semantic_model_reference: "https://registry.example.invalid/artifacts/full-model" + .into(), + }, + properties: properties(), + disclosure_profiles: vec![ + disclosure( + "public-disclosure", + &["publicName", "prederivedMask"], + Handling::Public, + ), + disclosure( + "limited-disclosure", + &["maskedSecret", "eventYear", "maskedOptional"], + Handling::Confidential, + ), + disclosure( + "caseworker-disclosure", + &["secretValue"], + Handling::Restricted, + ), + ], + operations: vec![list, read, lookup], + column_accounting: Vec::new(), + processing_descriptions: Vec::new(), + }], + metadata_visibility: CompiledMetadataVisibility { + service: Visibility::Public, + resources: Visibility::Public, + semantics: Visibility::Public, + classifications: Visibility::OperatorOnly, + processing: Visibility::OperatorOnly, + }, + } +} + +#[allow(clippy::too_many_arguments)] +fn representation( + id: &str, + access: CompiledAccess, + disclosure_profile: &str, + selectable: &[&str], + projected: &[&str], + processing: Handling, + disclosure: Handling, + transforms: &[&str], +) -> CompiledRepresentation { + let stem = format!("https://registry.example.invalid/artifacts/{id}"); + CompiledRepresentation { + id: id.into(), + access, + disclosure_profile: disclosure_profile.into(), + selectable_properties: selectable.iter().map(|value| (*value).into()).collect(), + projected_columns: projected.iter().map(|value| (*value).into()).collect(), + processing_handling: processing, + disclosure_handling: disclosure, + transform_inventory: transforms.iter().map(|value| (*value).into()).collect(), + schema_reference: format!("{stem}-schema"), + semantic_model_reference: format!("{stem}-model"), + context_reference: format!("{stem}-context"), + } +} + +fn disclosure(id: &str, properties: &[&str], handling: Handling) -> CompiledDisclosureProfile { + CompiledDisclosureProfile { + id: id.into(), + properties: properties.iter().map(|value| (*value).into()).collect(), + maximum_handling: handling, + } +} + +fn properties() -> Vec { + vec![ + property( + "publicName", + "public_name", + DataType::String, + true, + Handling::Public, + None, + ), + property( + "prederivedMask", + "prederived_mask", + DataType::String, + true, + Handling::Public, + None, + ), + property( + "maskedSecret", + "secret_value", + DataType::String, + true, + Handling::Confidential, + Some(CompiledTransform::PartialString { + identifier: "partial-string:suffix:4".into(), + reveal: PartialStringReveal::Suffix, + characters: 4, + }), + ), + property( + "eventYear", + "event_date", + DataType::Year, + true, + Handling::Confidential, + Some(CompiledTransform::DatePrecision { + identifier: "date-precision:date:year".into(), + source_type: DateInputType::Date, + precision: DatePrecision::Year, + }), + ), + property( + "maskedOptional", + "optional_value", + DataType::String, + false, + Handling::Confidential, + Some(CompiledTransform::PartialString { + identifier: "partial-string:suffix:4".into(), + reveal: PartialStringReveal::Suffix, + characters: 4, + }), + ), + property( + "secretValue", + "secret_value", + DataType::String, + true, + Handling::Restricted, + None, + ), + ] +} + +fn property( + name: &str, + source_column: &str, + data_type: DataType, + source_required: bool, + handling: Handling, + transform: Option, +) -> CompiledProperty { + CompiledProperty { + name: name.into(), + label: name.into(), + description: format!("Synthetic {name}"), + source_column: source_column.into(), + transform, + data_type, + codelist: None, + source_required, + semantic_iri: format!("https://registry.example.invalid/vocabulary/{name}"), + classification: EffectiveClassification { + privacy: "synthetic".into(), + privacy_scheme: "https://example.invalid/privacy".into(), + privacy_version: "1".into(), + institutional: handling_label(handling).into(), + institutional_scheme: "https://example.invalid/institutional".into(), + institutional_version: "1".into(), + handling, + handling_scheme: "https://id.registrystack.org/vocab/handling".into(), + handling_version: "1".into(), + status: ReviewStatus::Reviewed, + provenance_ref: "governance/review.yaml".into(), + }, + } +} + +fn handling_label(handling: Handling) -> &'static str { + match handling { + Handling::Public => "public", + Handling::Internal => "internal", + Handling::Confidential => "confidential", + Handling::Restricted => "restricted", + } +} diff --git a/docs/site/src/content/docs/configure/relay.mdx b/docs/site/src/content/docs/configure/relay.mdx index 1c2d732ed..aa7575830 100644 --- a/docs/site/src/content/docs/configure/relay.mdx +++ b/docs/site/src/content/docs/configure/relay.mdx @@ -1,6 +1,6 @@ --- title: Author a Registry Relay project -description: Turn a reviewed SQLite view into a checked Registry contract, synthetic test journey, and sealed Relay package. +description: Turn reviewed SQLite views into a checked Registry contract, governed representations, and a sealed Relay package. status: draft owner: registry-docs source_repos: @@ -16,402 +16,176 @@ standards_referenced: - govstack-digital-registries --- -Use `relayctl` to describe one institution-owned Registry, bind it to reviewed -SQLite views, and produce a sealed package for Registry Relay. This guide is -for the data publisher and technical implementer who can review the source -schema, public meaning, access rules, and permitted disclosure together. +Use `relayctl` to author one institution-owned Registry from reviewed SQLite views. +The governed contract names the Registry, its mandatory Registry Core context, published properties, +finite representations, access, and disclosure. +The database remains a source binding, not an API model. ## When to use this Use this guide after the [synthetic Relay tutorial](../../tutorials/publish-governed-sqlite-registry/) -works and the institution has selected an authoritative SQLite source. The -result is a candidate package for operator review, not a running service. +works and the Registry Authority has selected an authoritative SQLite source. +The result is a deployment candidate for institutional and operator review, not a running service. -Relay fits when callers need selected read-only Registry Records. Relay does -not turn every table into an endpoint. The project must name each resource, -operation, filter, public property, access rule, and semantic meaning that the -institution intends to publish. +Relay serves one Registry per process. +A resource is a governed Record type in that Registry, not a SQLite table and not a second Registry. ## Before you start -Prepare these inputs with the Registry Authority, the institution accountable -for the Registry: +Prepare a non-writable SQLite inspection copy, narrow reviewed views, a stable Registry identifier, +the Authority and operational roles, and synthetic fixtures. +Also agree which callers, purposes, row boundaries, fields, and representations each operation may +use. +Keep production Records, identifiers, credentials, and database files out of the project. -- One SQLite database or a non-writable copy for structural inspection. -- One or more narrow SQLite views that exclude internal columns and expose - stable record identifiers, revisions, lifecycle states, and recorded times. -- The Registry's stable identifier, Authority, scope, and identifier lifecycle - policy. -- The callers, purposes, fields, and filters each operation is allowed to use. -- Synthetic Records that exercise the same shapes and controlled values as the - real source without copying production data. -- `relayctl` on `PATH`. +## Inspect structure before assigning meaning -Read [semantics, classification, and disclosure](../../explanation/relay-semantics-and-disclosure/) -before assigning public property names or handling levels. - -## Initialize the project - -Create the contract and governance starter files: +Initialize the project, then inspect the source copy: ```sh relayctl init ./business-registry +relayctl inspect ./business-inspection.sqlite --starters ./business-registry/inspection ``` -The report lists the files it created: - -```json -{ - "status": "success", - "diagnostics": [], - "details": { - "kind": "initialized", - "files": [ - "registry.yaml", - "runtime.yaml", - "governance/identifier-lifecycle.yaml", - "governance/classification-review.yaml", - "governance/legal-basis.yaml", - "governance/processing.dpv.yaml", - "codelists/record-lifecycle.yaml" - ] - } -} -``` - -The generated values are prompts for review, not accepted policy. Production -checks refuse the project until you replace or approve every suggestion. - -## Inspect the SQLite structure - -Ask the source owner for a consistent, non-writable inspection copy of the -database. Do not copy a database while another process is writing it. Then ask -`relayctl` to record its structure without sampling row values: - -```sh -chmod a-w ./business-inspection.sqlite -relayctl inspect ./business-inspection.sqlite \ - --starters ./business-registry/inspection -``` - -The report includes tables, views, columns, declared SQLite types, nullability, -key membership, and one schema fingerprint. The final detail identifies the -generated starter: - -```json -{ - "status": "success", - "diagnostics": [], - "details": { - "kind": "schema-inspection", - "fingerprint": "sha256:", - "starter_file": "schema-starter.yaml" - } -} -``` - -Use `inspection/schema-starter.yaml` as a review aid. Copy the accepted -fingerprint, view names, and column bindings into `registry.yaml`; do not treat -the starter as a classification or publication decision. - -Create or revise the source views before continuing. A view is the database -boundary presented to Relay. It can exclude internal fields and normalize -codes before the Registry contract assigns public names. Callers cannot choose -tables, joins, expressions, source columns, or ordering at request time. - -## Identify the Registry - -Edit the generated `registry.yaml`. Replace the starter Registry identity with -institutional values: - -```yaml -registry: - registryIdentifier: urn:example:registry:registered-businesses - name: Registered business Registry - authority: - identifier: urn:example:institution:company-registrar - name: Company Registrar - operator: - identifier: urn:example:institution:digital-service-operator - name: Digital Service Operator - authoritativeScope: Legal business registrations in the declared jurisdiction - baseUri: https://business.example.invalid/registry/ - identifierLifecyclePolicyRef: governance/identifier-lifecycle.yaml -``` - -One Relay process serves one Registry. Related resources can share it when -they have the same Authority and authoritative scope. A resource is a governed -Record type within the Registry, not a SQLite table and not another Registry. - -Keep the institutional roles separate in the contract. The Registry Authority -is accountable for the Registry, the privacy controller determines processing -responsibilities, the publisher approves publication, the operator runs the -service, and the audit owner controls retained access evidence. - -## Bind one resource - -Each returned Record includes mandatory Registry and Record context: Registry -identifier, stable Record identifier, revision, lifecycle state, Authority, -recorded time, response schema, and semantic model. Bind the four source-backed -values to the reviewed view: - -```yaml -resources: - - id: registered-business - title: Registered business - semanticClass: local:RegisteredBusiness - source: - source: companies - view: relay_registered_businesses - recordContext: - recordIdentifier: {sourceColumn: registration_number} - revisionIdentifier: {sourceColumn: record_revision} - lifecycleState: - sourceColumn: lifecycle_state - codelist: codelists/record-lifecycle.yaml - recordedAt: {sourceColumn: recorded_at} -``` - -`recordedAt` is the time the Authority recorded that revision. It is not Relay -startup time, snapshot time, or response time. - -Declare every published property separately from its SQLite column: - -```yaml - properties: - legalName: - sourceColumn: legal_name - type: string - sourceRequired: true - semanticTerm: local:legalName - label: Legal name - description: Registered legal name of the organisation. - classification: - privacy: potentially-personal - institutional: public-by-law - handling: public - status: reviewed -``` - -`legalName` is the stable API property. `legal_name` remains a local storage -detail. `sourceRequired` checks the complete source Record, while the response -schema still permits a caller to request fewer authorized domain properties. +`inspect` records only SQLite structure: objects, columns, declared types, nullability, key +membership, and a schema fingerprint. +It does not read row values. +The starter and the generated identification candidates use that structure, authored roles, +codelist bindings, and the embedded digest-pinned core rule pack. +They are suggestions, never approved classification or publication truth. -## Declare only the required operations +Copy only accepted view bindings and the schema fingerprint into `registry.yaml`. +Unconfigured tables, columns, joins, expressions, and sort order remain unavailable to callers. -An operation can retrieve one Record by identifier, list a deterministic -collection, or perform a named exact lookup. Relay advertises those operations -as capabilities in the GovStack Consultation family. +## Keep Registry Core and published properties separate -This public business Registry needs list and retrieve: +Every successful Record always carries the Registry Core context: -```yaml - disclosureProfiles: - public-register: - properties: [registrationNumber, legalName, registrationStatus] - operations: - list: - access: public - disclosureProfile: public-register - filters: - - name: status - property: registrationStatus - type: controlled-code - allowUnfiltered: true - orderBy: [registrationNumber] - pagination: - defaultPageSize: 25 - maximumPageSize: 100 - read: - access: public - disclosureProfile: public-register -``` - -List filters are named, typed equality parameters. Relay reserves `pageSize`, -`cursor`, and `fields`. A caller cannot add an operator, sort, join, source -column, or expression. +- `registryIdentifier` and `recordIdentifier`. +- `revisionIdentifier`, `lifecycleState`, and source-owned `recordedAt`. +- `authorityIdentifier`, `schemaReference`, and `semanticModelReference`. +- `domainData`, containing only serializable published properties. -Use a named exact lookup for sensitive selectors. The lookup defines its -complete bounded request body, required scope, optional trusted purpose, -optional verified-claim row boundary, and maximum of one result. A lookup-only -resource publishes neither a collection route nor an identifier-read route. +Bind the Record identifier, revision, lifecycle, and recorded time to reviewed view columns. +`recordedAt` is when the Authority recorded the revision, not Relay startup, snapshot, or response +time. +Declare a published property separately from its source column, even for a one-to-one binding. +Its public name, type, semantic term, source requiredness, and output classification are part of +the contract. -## Complete classification and semantics +## Review classification as one governed input -Classify every published property and every hidden column used for identifiers, -revision, lifecycle, selectors, row boundaries, filtering, or ordering. The -technical handling order is `public`, `internal`, `confidential`, and -`restricted`. More restrictive data can narrow an operation but cannot widen -one, and `restricted` data cannot appear in a list. +Every published property has an output classification. +Every source-view column Relay processes also has a source-column classification, including hidden +Registry Core, selector, filter, order, row-binding, and transform-input columns. +Resource defaults reduce repetition, but compilation expands them to a complete effective +classification. -Set one stable local vocabulary base in `registry.yaml`: +Generate the deterministic, value-free review inputs beneath `generated/`: -```yaml -semantics: - localVocabulary: https://business.example.invalid/vocabulary/ +```sh +relayctl generate ./business-registry ``` -Relay generates a local vocabulary, JSON-LD context, JSON Schema, SHACL shape, -and codelist schemas from the reviewed contract. Mappings to the European -Commission Semantic Interoperability Community (SEMIC), PublicSchema, -schema.org, or another external vocabulary are optional governed files. Relay -does not infer equivalence between local and external terms. - -Bind each operation to its reviewed processing purpose, recipient class, legal -basis reference, and safeguards. Classify service, resource, semantic, -classification, and processing metadata as `public`, `operation-bound`, or -`operator-only`. A caller who receives a Record must also be able to retrieve -safe versions of the schema and semantic model linked from that Record. - -## Add synthetic HTTP journeys +The command writes: -Create `fixture.sql` with synthetic Records that cover valid, invalid, absent, -and boundary cases. Create `expected-http.yaml` with the requests and exact -responses the project must preserve. Do not copy production Records, tokens, or -identifiers into either file. +- `reports/identification-report.json` +- `reports/classification-inventory.json` +- `reports/representation-report.json` +- `reports/contextual-review-findings.json` +- `governance/classification-review-starter.yaml` -For one public identifier-read operation, begin with this minimal journey and -add cases for the rest of the contract: +The `classification-review.yaml` sidecar is strict and names the inventory it approves. +Use `generated` when the review accepts an identification report, `imported` for reviewed material +from another process, or `manual` for an institutional review without generated identification. +Only generated review binds an accepted copied report and its rule-pack identity. ```yaml -schemaVersion: relay.registrystack.org/http-journey/v1alpha1 -registry: urn:example:registry:registered-businesses -authorizations: {} -steps: - - id: identifier-read - request: - method: GET - path: /v2/resources/registered-business/records/BIZ-SYNTH-0001 - expect: - status: 200 - recordIdentifier: BIZ-SYNTH-0001 -``` - -`fixture.sql` must create the reviewed view named in `registry.yaml` and insert -a synthetic `BIZ-SYNTH-0001` Record with every required source value. Add at -least one invalid row to prove source-shape refusal and one unresolved request -that does not reveal whether a Record exists. - -The journey must cover every published operation, permitted filter, expected -field subset, protected access rule, and safe refusal that matters to the -Registry. `relayctl test` runs these requests through the Relay HTTP router -against an isolated SQLite database built from `fixture.sql`. - -## Check and package the project - -Run the production check: +apiVersion: relay.registrystack.org/classification-review/v1 +kind: ClassificationReview +registryIdentifier: urn:example:registry:registered-businesses +classificationInventoryDigest: sha256: +method: manual +reviewer: urn:example:institution:company-registrar +reviewDate: 2026-08-10 +status: reviewed +rationaleRef: governance/classification-review-rationale.md +``` + +Production compilation refuses a missing, non-reviewed, stale, or digest-mismatched sidecar. +A relevant contract, schema, source-column, or classification change invalidates review. +A rule-pack change matters only where that pack informed the review. + +## Define reviewed representations + +Each list, read, or named exact-lookup operation has a finite ordered map of named representations +and exactly one `defaultRepresentation`. +If an operation has any public representation, its default must also be public. This keeps omission +truthful for anonymous callers and the generated public OpenAPI. +Each representation selects one disclosure profile and one access rule. +Its profile defines the maximum property set that can reach `domainData`. + +Callers may omit `representation` to select the declared default, or supply one named +representation. +Relay authorizes the supplied representation exactly as requested. +An unknown representation, invalid bearer, or denial never falls back to the default or another +representation. +The `fields` parameter can only select a non-empty subset of the selected representation's public +properties. +It cannot add a property, select a source column, change a transform, weaken handling, or bypass +an access or row boundary. +Registry Core remains present. + +## Account for processing and disclosure + +Technical handling is ordered from `public` through `internal` and `confidential` to `restricted`. +Processing handling is the most restrictive level across every column used to answer the request. +Disclosure handling is the most restrictive level among the properties a representation can +serialize. +Compiler validity, audit context, cache eligibility, and source processing account for the +processing level, even where the released representation is less restrictive. + +A public representation cannot transform a non-public source column. +Instead, bind it to a reviewed pre-derived public SQLite view column. +Classifications can restrict an operation, but cannot create a route, scope, purpose, consent, +lawful basis, or row authority. + +Relay supports only two compiled, deterministic transforms: + +- `partial-string` emits the fixed Relay marker `***` and a configured bounded prefix or suffix. + A value no longer than the reveal length emits only `***`. +- `date-precision` accepts a canonical `date` or `date-time` and emits a `year` or `year-month` + output property with its own type, semantic term, and classification. + +Invalid, noncanonical, oversized, or required missing transform input fails without releasing a +value. +Hashing, pseudonyms, encryption, regular-expression replacement, caller-defined masks or +expressions, dynamic per-request masking, and a free-form policy engine are not supported. + +## Check, test, and package the complete revision + +Run the production gate, generate reviewable artifacts, and replay synthetic HTTP fixtures before +handoff: ```sh relayctl check ./business-registry --production -``` - -The report must identify `check`, production mode, and the accepted contract -revision: - -```text -relayctl check -{ - "status": "success", - "diagnostics": [], - "details": { - "kind": "check", - "production": true, - "contract_revision": "sha256:" - } -} -``` - -Generate the artifacts: - -```sh relayctl generate ./business-registry -``` - -The result identifies `generate`, the same contract revision, and an -`artifacts` inventory. Now run the synthetic journey: - -```sh relayctl test ./business-registry -``` - -The test result identifies `test`, the same contract revision, and one -`"passed": true` entry for every HTTP step. Any diagnostic or failed step -stops the handoff. - -Before changing an approved project, keep its previous revision in a separate -directory or worktree. Classify the proposed change: - -```sh relayctl diff ./approved-business-registry ./business-registry -``` - -Review any new property, weaker classification, broader operation, removed row -boundary, expanded purpose or scope, changed source view, or semantic mapping. -Git and CI provide the approval workflow. Relay does not include an -administration or approval service. - -Each change has a class, impact, location, and stable description. For example, -expanding a disclosure profile appears as: - -```json -{ - "class": "disclosure-expanded", - "impact": "widening", - "location": "resources.registered-business.operations.list.disclosureProfile", - "description": "the maximum disclosure property set expanded" -} -``` - -After approval, create a new output directory: - -```sh relayctl package ./business-registry --output ./business-registry-package ``` -The package result has this stable shape: - -```text -relayctl package -{ - "status": "success", - "diagnostics": [], - "details": { - "kind": "package", - "manifest": { - "packageRevision": "sha256:", - "contractRevision": "sha256:", - "sourceSchemaFingerprints": {"companies": "sha256:"} - } - } -} -``` - -The complete report also records governed-file digests, generated artifacts, -media types, and visibility. Give the package, matching source, and runtime -bindings to the operator as one revisioned deployment candidate. - -## Verify the handoff - -Confirm that these statements are true before deployment: - -- `relayctl check --production`, `generate`, and `test` all report success for - the same contract revision. -- The approved diff contains no unexplained disclosure, access, source, or - semantic change. -- The package directory did not exist before packaging and is non-writable - after the handoff. -- The operator has the matching SQLite source or approved live-source process, - but no production data is inside the package. - -Continue with [Operate Registry Relay](../../operate/relay/) to bind the package -to deployment paths, authentication, audit, limits, and a listener. +`check --production` and `package` refuse incomplete governance and unsafe package inputs. +The package contains the contract's governed file closure and generated artifacts, but not SQLite +data or fixtures. +Relay loads one complete package at startup and does not merge, reload, overlay, or fall back to a +different interpretation. ## Troubleshooting | Symptom | Cause | Fix | | --- | --- | --- | -| `relayctl inspect` refuses the database | The inspection copy is writable, unsafe, or reached through a symlink | Create a non-writable copy on a physical path and inspect that copy. | -| `check --production` reports suggested governance | A generated starter was not institutionally reviewed | Replace or approve the starter and record `status: reviewed` in the governed file. | -| The schema fingerprint changed | The SQLite structure no longer matches the contract | Review the database migration, update bindings intentionally, and rerun the complete change workflow. | -| A field cannot be added to `fields` | The operation's disclosure profile does not include that public property | Add the property only after classification, semantic, processing, and disclosure review. | -| Packaging refuses the output | The destination already exists | Choose a new revisioned directory. Packaging never overwrites an existing package. | +| `inspect` refuses the source | The inspection database is unsafe or writable | Create a non-writable physical-path copy and retry. | +| Production checking reports a review error | The sidecar is missing, not reviewed, stale, or does not match the inventory | Complete the institutional review and regenerate the affected report. | +| A field is rejected | It is not in the selected representation | Review and change the governed representation, then repeat the full workflow. | +| Packaging refuses the destination | The output directory exists or its closure is unsafe | Select a new empty revisioned directory. | diff --git a/docs/site/src/content/docs/explanation/governed-registry-publication.mdx b/docs/site/src/content/docs/explanation/governed-registry-publication.mdx index 1b78ea209..52df83180 100644 --- a/docs/site/src/content/docs/explanation/governed-registry-publication.mdx +++ b/docs/site/src/content/docs/explanation/governed-registry-publication.mdx @@ -1,6 +1,6 @@ --- title: How Relay publishes a governed Registry -description: Understand how Registry identity, reviewed SQLite views, fixed read operations, disclosure, and runtime controls form one Relay API. +description: Understand how one reviewed Registry contract governs SQLite reads, representations, audit, and startup behavior. status: draft owner: registry-docs source_repos: @@ -14,170 +14,114 @@ standards_referenced: - universal-dpi-safeguards --- -Registry Relay is for an institution that needs to publish selected read-only -Registry Records without exposing its database as a general API. The -institution reviews one Registry contract that connects identity, meaning, -disclosure, authorization, provenance, documentation, and runtime behavior to -specific SQLite views. - -## Start with the Registry, not the database - -A Relay process serves one Registry in one administrative trust domain. A -Registry is an authoritative collection with a stable identifier, name, -accountable Registry Authority, optional technical operator, declared scope, -and base URI. - -A resource is one governed Record type inside that Registry. It is not a table -and not another Registry. One reviewed SQLite view can support several -resources, and several tables can feed one view. A database object without a -contract binding is not visible to Relay. - -Every returned Record has two parts: - -- Mandatory record context identifies the Registry, Record, revision, - lifecycle state, Authority, recorded time, response schema, and semantic - model. The contract calls this Registry Core context. -- `domainData` contains only the properties permitted by the operation's - disclosure profile and any smaller subset requested by the caller. - -The pair `(registryIdentifier, recordIdentifier)` identifies a Record. A JSON -for Linked Data (JSON-LD) `@id` can add a global Internationalized Resource -Identifier, but does not replace either authoritative identifier. - -## Compile one reviewed agreement - -```mermaid -flowchart LR - contract["Registry contract
identity · resources · access · disclosure"] - sqlite[("Reviewed SQLite views")] - compiler["Relay compiler"] - package["Sealed package
queries · artifacts · revisions"] - runtime["Relay runtime
authentication · audit · limits"] - api["Registry API
JSON · JSON-LD · discovery"] - - contract --> compiler - sqlite --> compiler - compiler --> package - package --> runtime - sqlite --> runtime - runtime --> api -``` - -The compiler resolves source bindings, validates the SQLite structure, -expands classifications, fixes the allowed queries, derives access and -disclosure plans, and generates OpenAPI and semantic artifacts. The sealed -package binds those results to the governed input digests and one contract -revision. - -At request time, callers can select only a compiled operation, declared -equality filters, a bounded page size and cursor, and fewer properties from the -authorized disclosure profile. They cannot introduce SQL, choose a table, -change ordering, or turn a private column into a public property. - -## Offer only the required read capabilities - -The GovStack Digital Registries specification groups Registry reads under the -Consultation API family. Relay advertises only the capabilities compiled for a -deployment: - -| Contract operation | Advertised capability | Result | +Registry Relay exposes selected, read-only Registry Records without making the source database a +general API. +One reviewed contract connects Registry identity, SQLite views, mandatory Registry Core context, +operations, representations, processing, disclosure, generated artifacts, and audit. + +## Start with one Registry + +One Relay process serves one Registry and one administrative trust domain. +The Registry has a stable identifier, Authority, scope, and base URI. +A resource is a Record type within that Registry, not a table or a second Registry. +Unbound database objects remain invisible. + +Each successful Record preserves Registry Core: Registry and Record identifiers, revision, +lifecycle state, Authority, recorded time, response-schema reference, and semantic-model reference. +Only `domainData` varies by representation and requester field subset. +The pair `(registryIdentifier, recordIdentifier)` remains authoritative when JSON for Linked Data +(JSON-LD) adds a derived `@id`. + +## Compile a complete reviewed agreement + +The compiler validates the source schema, binds fixed parameterized queries, expands +classifications, validates Registry Core and complete source rows, derives access and disclosure +plans, and generates OpenAPI and semantic artifacts. +`relayctl package` seals the governed input closure and generated inventory into one revision. + +Runtime configuration supplies deployment-local paths, listener, issuer, audit sink, limits, and +secrets. +It cannot add or weaken a resource, operation, representation, access rule, classification, +semantic mapping, or disclosure decision. +The service loads one complete checked package at startup. +It has no hot reload, overlay, partial activation, fallback interpretation, or mutable authoring +project input. + +## Publish representations, not database columns + +Each compiled list, identifier-read, or named exact-lookup operation has a finite map of named +representations and one default. +The representation binds one access rule to one disclosure profile. +The profile is the maximum published-property set for that response. + +Callers choose the default by omitting `representation`, or request a supplied representation by +name. +Relay authorizes the exact choice and never falls back when it is unknown or denied. +`fields` runs after representation selection and can only narrow `domainData` within that profile. +It cannot introduce SQL, source columns, joins, filters, ordering, expressions, transformations, or +new authorization. +Registry Core cannot be removed. + +The compiler keeps source processing and disclosure distinct. +Processing handling covers all columns Relay uses, including hidden identifiers, filters, and row +boundaries. +Disclosure handling covers the serializable properties of the representation. +The processing floor drives compiler validity, source projection, cache eligibility, and audit +context. Authorization remains the representation's explicit access rule. + +## Offer only declared read capabilities + +Relay compiles only the operations the publisher declares: + +| Operation | Consultation capability | Result | | --- | --- | --- | -| Identifier read | `consultation.retrieve` | One Record by its stable identifier. | -| Deterministic list | `consultation.list` | A bounded collection with declared filters and ordering. | -| Named exact lookup | `consultation.search` | One resolved Record or one indistinguishable unresolved outcome. | - -An exact lookup does not return candidates, confidence scores, rankings, or a -matching explanation. A lookup-only sensitive resource publishes no list or -identifier-read route, even when a token contains broader scopes. - -`GET /v2` identifies the Registry and lists the capabilities visible to the -caller. The service metadata names the GovStack Digital Registries and API -Design Guide versions used as alignment targets. This is alignment evidence, -not a conformance or certification claim. - -## Keep access and disclosure separate - -A protected request crosses six distinct gates: - -1. JSON Web Token verification establishes one principal, audience, issuer, - lifetime, token identifier, and set of scopes. -2. The access rule requires the operation scope and can also require a trusted - purpose or an authority-to-row claim. -3. Relay builds the fixed parameterized query for the reviewed view. -4. The selected source Record passes complete source-shape validation. -5. The disclosure plan emits the operation's maximum property set or a - caller-requested subset. -6. Durable terminal audit succeeds before Relay releases the held response. - -Purpose and row authority come from verified token claims named by the -contract. Request headers and query parameters cannot create authority. -Different operations can expose different reviewed property sets. Within one -operation, two clients share the same maximum property set; a caller can only -request less. - -## Protect metadata with the same boundary - -Registry identity is public. Resource, schema, semantic, classification, and -processing artifacts can be public, operation-bound, or operator-only. -Operation-bound artifacts require the same static access rule as the Record -that links to them. A public sibling resource does not reveal a protected -resource's existence or artifacts. - -Relay publishes a safe public OpenAPI document and retains the full OpenAPI -document in the sealed package. The public document omits protected request -shapes and operator-only metadata. Relay does not generate caller-specific -OpenAPI documents at request time. - -## Choose reproducibility or live publication - -Snapshot mode captures an immutable read-only SQLite file with stable identity, -digest, and reproducible source revision. Live read-only mode allows a separate -trusted publisher to update a compatible database while Relay keeps one fixed -contract and one consistent read transaction per request. - -Snapshot is stronger but optional. Live sources are explicitly unversioned, -support identifier read and named exact lookup, and return no ETag or cacheable -response. Both profiles deny writes, arbitrary SQL, undeclared functions, -schema drift, unbounded rows, and unbounded response values. The -[operations guide](../../operate/relay/) compares the deployment tradeoffs. - -## Keep Relay, Evidence Gateway, and Mint distinct - -Relay responses are unsigned. Transport Layer Security (TLS) protects -transport, OAuth 2.0 protects controlled operations, and revisions plus -tamper-evident audit support accountability. - -Evidence Gateway is the separate product for portable signed, -minimum-disclosure assertions. Registry Mint is an optional OAuth issuer when -an institution lacks a suitable authorization server. Relay does not issue -assertions or tokens and has no production runtime dependency on either -component. - -## Know the product boundary - -Relay publishes governed, semantically described, read-only Registry Records -from SQLite. Relay is not: - -- A generic SQLite REST generator or SQL proxy. -- A write API, Registry administration service, or workflow engine. -- A Resource Description Framework (RDF) store, SPARQL endpoint, or runtime - inference engine. -- A general policy engine, consent system, or identity provider. -- A matching, eligibility, case-management, aggregate, or analytics service. -- A credential issuer or signed-assertion service. -- A multi-Registry hosting layer. - -SQLite is the supported source. PostgreSQL, GeoJSON and SpatiaLite, other API -families, historical retrieval, and runtime semantic inference are not -supported by this contract. +| Identifier read | `consultation.retrieve` | One Record by stable identifier. | +| Deterministic list | `consultation.list` | A bounded collection with declared equality filters and order. | +| Named exact lookup | `consultation.search` | One resolved Record or an indistinguishable unresolved outcome. | + +Exact lookup is not record matching: Relay returns no candidates, scores, rankings, or matching +explanations. +The API does not provide row-value profiling, machine-learning inference, arbitrary expression +evaluation, dynamic per-request masking, a free-form policy engine, consent or workflow, write +operations, or response signing. + +Relay supports two fixed transforms under the contract: partial strings use the `***` marker, and +date precision produces typed `year` or `year-month` output. +It does not offer caller-defined masks, hashes, pseudonyms, encryption, or transform expressions. + +## Make records and artifacts visible together + +Registry identity is public. +Resource, schema, semantic, classification, and processing artifacts can be public, +operation-bound, or operator-only. +Relay refuses a contract unless an audience that can receive a Record can retrieve safe projections +of its referenced schema and semantic model. + +The public OpenAPI endpoint is a deterministic safe projection. +The sealed package retains the complete generated OpenAPI document. +Relay does not create caller-specific OpenAPI documents at request time. +Generated semantics express local reviewed terms; optional external mappings state a reviewed +relation and do not claim certification or conformance. + +## Audit the released boundary + +Relay records a durable attempt before source access and a terminal event before the exact response +bytes leave the service. +Failure of either audit gate refuses source access or withholds the response. +The events are value-free: they bind the Registry, resource, operation, representation, disclosure +profile, selected properties, processing and disclosure handling, transform identifiers, contract +revision, and source revision, but exclude tokens, selectors, raw principals, source values, and +response values. + +Deployment TLS protects transport and OAuth 2.0 access tokens control protected operations. +Relay responses are not signed. +Evidence Gateway remains the separate product for signed, minimum-disclosure assertions, and +Registry Mint is an optional token issuer rather than a Relay runtime dependency. ## Next -- [Publish a governed SQLite registry](../../tutorials/publish-governed-sqlite-registry/) - for a complete local run with synthetic data. -- [Review semantics, classification, and disclosure](../relay-semantics-and-disclosure/) - before naming and classifying real fields. -- [Author a Registry Relay project](../../configure/relay/) to bind an - institution-owned SQLite view. -- [Operate Registry Relay](../../operate/relay/) to deploy one reviewed - package. +- [Review semantics, classification, and disclosure](../relay-semantics-and-disclosure/) for the + representation and governance model. +- [Publish a governed SQLite registry](../../tutorials/publish-governed-sqlite-registry/) for a + synthetic end-to-end run. +- [Operate Registry Relay](../../operate/relay/) for deployment controls. diff --git a/docs/site/src/content/docs/explanation/relay-semantics-and-disclosure.mdx b/docs/site/src/content/docs/explanation/relay-semantics-and-disclosure.mdx index 04adf372b..f2ce93485 100644 --- a/docs/site/src/content/docs/explanation/relay-semantics-and-disclosure.mdx +++ b/docs/site/src/content/docs/explanation/relay-semantics-and-disclosure.mdx @@ -1,6 +1,6 @@ --- title: Semantics, classification, and disclosure in Relay -description: Understand how Relay generates a local semantic model, classifies source columns, and limits each response to reviewed properties. +description: Understand how Relay separates source processing, public meaning, reviewed representations, and bounded disclosure. status: draft owner: registry-docs source_repos: @@ -16,187 +16,124 @@ standards_referenced: - universal-dpi-safeguards --- -Registry Relay makes meaning and data handling part of the API contract. An -institution can start without an existing JSON for Linked Data (JSON-LD) -context, Shapes Constraint Language (SHACL) shape, or external vocabulary -mapping. Relay generates a local semantic model from the reviewed Registry -contract, while external alignments remain optional and explicit. +Registry Relay makes meaning, processing, and disclosure part of one reviewed Registry contract. +It generates a local semantic model from that contract, but the contract never promotes a SQLite +column, identification candidate, or classification suggestion into public truth by itself. -## Separate four layers of meaning +## Separate source structure from public meaning -One SQLite column can participate in four distinct concerns: +A source column is a reviewed local binding. +A published property is a stable API name with a type, semantic term, label, description, source +requiredness, and output classification. +One source column can support more than one published property, and a published property can preserve its +meaning across a reviewed storage migration. -| Layer | Question | Example | +Relay's schema-only identification reads observed SQLite structure, declared types, key metadata, +codelist bindings, authored roles, and an embedded digest-pinned core rule pack. +It does not profile or read row values, make machine-learning inferences, or auto-approve a result. +Candidates record their evidence and categorical confidence. +Conflicts remain uncertain and require institutional review. + +## Preserve Registry Core in every representation + +Every successful Record has mandatory Registry Core context: Registry and Record identifiers, +revision, lifecycle state, Authority, recorded time, and links to the response schema and semantic +model. +`domainData` is the only selectable part of the Record. +`fields` can remove published properties from `domainData`, but it cannot remove Registry Core. + +This split explains why Relay produces two kinds of validation artifact. +The compiler validates hidden source bindings. +The complete-record schema and Shape Constraint Language (SHACL) shape retain source requiredness +for every governed output property. +Each representation schema validates Registry Core and the domain properties that representation +permits when they are present. + +## Classify output and processing separately + +Each published property has an output classification. +Each column Relay processes has a source-column classification, including unpublished Registry Core, +filter, order, selector, row-boundary, revision, and transform-input columns. +The source classification is not a duplicate of the published property's classification: it protects +the data Relay had to process even when Relay does not serialize it. + +The technical handling order is `public`, `internal`, `confidential`, then `restricted`. +The compiler derives two floors for each representation: + +| Floor | Includes | Controls | | --- | --- | --- | -| Source binding | Where does the value come from? | `legal_name` in `relay_registered_businesses`. | -| Domain meaning | What does the published property mean? | `local:legalName`. | -| Classification | How sensitive is the property, and how must Relay handle it? | Potentially personal, public by law, and public handling. | -| Processing description | Why is an operation offered, to which recipient class, and with which safeguards? | Statutory publication to the public. | - -The public property is the centre of the model. A source column is its local -binding, not its API name or semantic identity. The same public property can -therefore keep a stable meaning when an institution changes its storage schema, -provided the reviewed binding and source fingerprint change together. The -fingerprint is a digest of the reviewed SQLite structure, not of its row values. - -## Generate a local semantic model first - -Every Registry contract declares a stable local vocabulary base. Every -resource names a local class, and every property names a local term. Relay then -generates: - -- A local JSON-LD vocabulary with classes, properties, labels, descriptions, - data types, source requiredness, and codelist references. -- One JSON-LD context for each visible operation. -- A JSON Schema for each permitted response representation. -- A SHACL shape for each operation and a complete operator-only source shape. -- Codelist schemas and links. -- Capability, classification, and processing descriptions. - -The local model is usable without an external mapping. Stable local terms make -Records interpretable and give later mapping work an explicit source -vocabulary. Relay does not guess that two terms are equivalent. - -## Add external alignments deliberately - -An institution can add a governed mapping file when a suitable public -vocabulary exists. Each entry records: - -- The local class or property. -- The external class or property. -- Whether the relation is exact, close, broad, narrow, or related. -- The external profile identifier and reviewed version. -- A digest of the reviewed external profile material. - -For example, an institution can map `local:RegisteredBusiness` to the SEMIC -Core Business Vocabulary's `LegalEntity` class with a `close` relation. The -relation states a reviewed alignment without claiming that the two models are -identical. - -The European Commission Semantic Interoperability Community (SEMIC), -PublicSchema, schema.org, and domain vocabularies are possible mapping targets, -not runtime dependencies. Relay neither fetches nor infers from remote -vocabulary content when serving a request. The local term remains authoritative -for the Relay contract. - -## Validate the source and response separately - -The complete source Record and a caller-minimized response have different -requiredness rules. - -The operator-only full JSON Schema and SHACL shape validate every -`sourceRequired` property and mandatory Record-context binding in the reviewed -view. Relay does not coerce or partially release an invalid selected row. For a -sensitive exact lookup, a missing Record, ambiguous result, hidden row, and -unsafe row can share the same unresolved response so the result does not reveal -which condition occurred. - -An operation's response schema always requires the mandatory Registry and -Record context. Domain properties are constrained when present but can be -omitted with the `fields` parameter. The schema linked from a returned Record -therefore validates the representation the caller received, not the complete -source row. - -## Classify properties and hidden columns - -Each published property carries three classification dimensions: - -- A privacy category describes whether the value is personal, identifying, - sensitive, derived, or another reviewed category. -- An institutional classification uses the Registry Authority's own scheme. -- A technical handling level selects the controls Relay must apply. - -The technical handling order is `public`, `internal`, `confidential`, and -`restricted`. Relay applies the most restrictive effective handling across all -properties and source columns used by the operation. - -Hidden columns still matter. Record revision, lifecycle, recorded time, -selectors, row-authority claims, filters, and ordering can affect an access or -release decision without appearing in `domainData`. Every reviewed hidden -column needs a technical classification so a non-returned field cannot weaken -the operation's handling level. - -Resource defaults keep authoring compact. The compiler expands those defaults -and any property or column exceptions into a complete classification. Generated -classifications remain suggestions until an institution reviews them; -production checks refuse incomplete classification. - -## Allow classification to restrict, never grant - -Classification can narrow an operation but cannot create authority: - -- `public` data is anonymous only through an explicitly public operation. -- Non-public handling requires authentication, an operation scope, - `Cache-Control: no-store`, and durable audit without Registry values. -- `confidential` and `restricted` handling prevents public classification and - processing metadata. -- `restricted` data cannot be exposed through collection listing. - -A classification label does not invent a purpose, lawful basis, consent, or -row authority. Those remain explicit reviewed access and processing fields. A -classification change can reduce availability or trigger review, but cannot -create a route or grant a token scope. - -## Use DPV for governance metadata - -The [Data Privacy Vocabulary (DPV) 2.3](https://w3c-cg.github.io/dpv/2.3/dpv/) -can describe purposes, processing, parties, recipients, legal context, and -technical or organisational measures. A domain vocabulary describes what a -Registry fact means; DPV describes why and how an operation processes it. - -A processing description can link to a reviewed DPV profile. Relay does not -use DPV as its runtime policy language. The service executes its smaller typed -access contract and does not evaluate arbitrary Resource Description Framework -(RDF), DPV rules, Open Digital Rights Language (ODRL), or remote content. DPV -2.3 is a W3C Community Group report, so an institution pins and reviews the -chosen version. - -## Treat disclosure as a maximum - -Every operation names one reviewed disclosure profile. Its property list is -both the default and the maximum. A caller can request a non-empty subset with -`fields`, but cannot add a property, select a source column, change a -derivation, bypass row authority, or reduce authentication, audit, quota, -handling, or metadata controls. - -This is requester minimization, not per-client field authorization. Two clients -of the same operation share one maximum property set. Use separate named -operations when two institutional purposes need different reviewed -representations. Keep the same Registry and Record identifiers when both -operations describe the same Record. - -## Publish semantics at the Record's visibility - -Every Record carries `schemaReference` and `semanticModelReference`. Relay -refuses a project where the successful audience cannot retrieve safe versions -of both artifacts. The JSON-LD context is linked separately because a context -maps terms to Internationalized Resource Identifiers but does not define the -complete semantic model by itself. - -Metadata visibility is part of disclosure: - -- `public` artifacts can be retrieved anonymously. -- `operation-bound` artifacts require the same static access rule as their - operation. -- `operator-only` artifacts remain in the sealed package and are not served - over HTTP. - -The full source schema, complete SHACL shape, authored mapping files, and -classification inventory can remain operator-only. Each successful caller -still receives the operation-specific schema and semantic model needed to -interpret the returned Record. - -These controls provide technical evidence for data minimization, -transparency, protection during use, and change-impact review. They do not -create lawful basis, institutional accountability, remedy, independent -oversight, or certification. Those responsibilities remain with the Registry -Authority and its governance environment. +| Processing handling | Every source column used to answer the operation | Compiler validity, source use, audit context, and cache eligibility. | +| Disclosure handling | Properties serializable by the representation | The sensitivity of the releasable output. | + +The processing floor applies even when the output floor is less restrictive. +For example, a public representation cannot conceal a non-public source column through a transform. +It must instead read a reviewed pre-derived public column from the SQLite view. + +Classifications may reduce availability, but do not grant access. +They do not create a scope, purpose, row authority, lawful basis, consent decision, or operation. + +## Review classification as a complete artifact + +Resource defaults can make authoring concise, but compilation expands every property and processed +source column to a complete effective classification. +`relayctl generate` produces deterministic, value-free identification, classification inventory, +representation, and contextual-finding reports, plus a classification-review starter. + +The closed `ClassificationReview` sidecar binds the Registry identifier and classification-inventory +digest to a reviewer, date, rationale, status, and one method: `generated`, `imported`, or `manual`. +Generated review also binds the accepted copied identification report and rule pack. +Imported and manual reviews are first-class methods and do not require an identification report. +Production compilation fails for a missing, unreviewed, stale, or digest-mismatched sidecar. + +## Use finite governed representations + +An operation has a finite ordered map of named representations and exactly one default. +Each representation owns one access rule and one disclosure profile. +The profile is the largest set of published properties it may disclose. + +The request parameter `representation` selects a named representation. +When absent, Relay uses the declared default. +When present, Relay authorizes that exact representation: an invalid bearer, denied request, or +unknown name does not fall back to another representation. +After selection, `fields` may request only a non-empty subset of that representation's properties. +It cannot select a source column, switch profiles, change a transformation, bypass a row boundary, +or lower the compiled handling, audit, quota, metadata, or cache controls. + +This is requester minimization, not dynamic per-request masking or attribute authorization. +Relay has no free-form policy engine or arbitrary expression language. + +## Apply only bounded transforms + +The transform set is deliberately finite and deterministic. +`partial-string` produces the Relay-owned marker `***` with a bounded Unicode-scalar prefix or +suffix. +If the input has no more characters than the configured reveal length, the output is `***` alone. +`date-precision` turns a canonical `date` or `date-time` into a `year` or `year-month` property. +The transformed property has its own semantic term, output type, and classification. + +Invalid, noncanonical, oversized, or incompatible required input fails without exposing source +values. +Optional null input omits the property. +Relay does not provide hashing, pseudonyms, encryption, regular-expression replacement, geographic +or numeric transformations, codelist remapping, caller-defined masks, or dynamic masking policy. + +## Generate semantics without pretending equivalence + +Relay generates a local JSON for Linked Data (JSON-LD) vocabulary and context, JSON Schema, SHACL, +codelist schemas, capability metadata, and representation-specific artifacts. +An institution can add a reviewed and digest-pinned mapping to an external vocabulary with an +explicit exact, close, broad, narrow, or related relation. +Relay does not infer that relation or fetch remote vocabulary content while serving a request. + +Data Privacy Vocabulary (DPV) metadata can describe processing purpose, parties, recipients, legal +context, and safeguards. +DPV is a governance projection, not Relay's runtime policy language. +Relay does not evaluate arbitrary Resource Description Framework (RDF), DPV, or Open Digital Rights +Language (ODRL) rules. ## Next -- [Understand governed Registry publication](../governed-registry-publication/) - for the wider Relay product boundary. -- [Author a Registry Relay project](../../configure/relay/) to apply this model - to institution-owned SQLite views. -- [Operate Registry Relay](../../operate/relay/) to enforce artifact visibility, - authentication, audit, and source controls at deployment. +- [Understand governed Registry publication](../governed-registry-publication/) for the overall + contract and runtime boundary. +- [Author a Registry Relay project](../../configure/relay/) for the review and package workflow. +- [Operate Registry Relay](../../operate/relay/) for startup, audit, and revision controls. diff --git a/docs/site/src/content/docs/operate/relay.mdx b/docs/site/src/content/docs/operate/relay.mdx index 66893d53e..42073576d 100644 --- a/docs/site/src/content/docs/operate/relay.mdx +++ b/docs/site/src/content/docs/operate/relay.mdx @@ -1,6 +1,6 @@ --- title: Operate Registry Relay -description: Deploy one sealed Registry package with read-only SQLite, authentication, audit, limits, and a private listener. +description: Deploy one sealed Registry package with read-only SQLite, authentication, auditing, limits, and a private listener. status: draft owner: registry-docs source_repos: @@ -11,299 +11,144 @@ locale: en standards_referenced: [] --- -Deploy one reviewed Registry Relay package without allowing local runtime -settings to change its Registry identity, API, access rules, or disclosure. This -guide is for the Unix service operator who owns deployment paths, secrets, -authentication, audit retention, limits, readiness, and revision replacement. +Deploy one reviewed Relay package without allowing deployment configuration to change Registry +identity, Registry Core, source bindings, representations, access, classifications, or disclosure. +This guide is for the operator who owns service paths, sources, secrets, issuers, audit retention, +limits, readiness, and replacement of complete revisions. ## When to use this -Use this guide after the data publisher gives you a sealed package, the -matching SQLite source, and an approved runtime plan. Return to -[Relay project authoring](../../configure/relay/) if the package or source -contract still needs review. +Use this guide after a data publisher supplies a package produced by `relayctl package`, the +matching SQLite source, and an approved change report. +Return to [Relay project authoring](../../configure/relay/) if any of those inputs changes. -Relay currently runs one Registry per process. Repeat the deployment as a -separate service when another Registry has a different Authority or +Relay runs one Registry per process. +Deploy another Registry under a separate service when it has a different Authority or administrative trust boundary. ## Before you start -Prepare: +Prepare a dedicated Unix service identity, a private listener behind Transport Layer Security +(TLS) termination, a sealed package, the matching snapshot or live read-only source, an audit +location, independent audit and cursor integrity keys, and a token issuer for protected +representations. +Do not place the package, source, secret, or audit path in a shared writable directory. -- A dedicated Unix service identity, shown as `` and - `` in this guide. -- The `relay` binary and one sealed package produced by `relayctl package`. -- The matching SQLite snapshot, or a live SQLite source maintained by a - separate trusted publisher. -- An audit retention location and two independent integrity keys. -- For protected operations, one OpenID Connect issuer with discovery and key - endpoints reachable during startup. -- A reverse proxy or ingress that terminates Transport Layer Security (TLS). +Relay validates trusted path components before use. +They must not be symbolic links or writable by group or world, and must be owned by root or the +service identity. +The service fails closed on non-Unix platforms where it cannot enforce those ownership and mode +checks. -Run Relay on a private or loopback listener. Do not place the package, source, -secrets, or audit file in a shared writable directory. +## Bind deployment inputs without editing the package -## Prepare trusted paths - -Create separate locations for trusted configuration, read-only source data, -secret material, and writable audit state: - -```sh -sudo install -d -o root -g root -m 0755 /etc/relay/business -sudo install -d -o root -g root -m 0755 /etc/relay/business/secrets -sudo install -d -o root -g root -m 0755 /srv/registries -sudo install -d -o -g -m 0700 /var/lib/relay/business -``` - -The resulting layout is: - -```text -/etc/relay/business/ - runtime.yaml - package/ - secrets/ - audit-integrity-key - cursor-integrity-key -/srv/registries/ - business.sqlite -/var/lib/relay/business/ - audit.jsonl -``` - -Copy the package and snapshot into place using the institution's deployment -tooling. Make the package tree and snapshot non-writable to the Relay identity. -Make each secret readable only by that identity: - -```sh -sudo chown -R root:root /etc/relay/business/package -sudo chmod -R go-w /etc/relay/business/package -sudo chown root:root /srv/registries/business.sqlite -sudo chmod 0444 /srv/registries/business.sqlite -sudo chown : /etc/relay/business/secrets/* -sudo chmod 0400 /etc/relay/business/secrets/* -``` - -Relay validates every trusted path component before use. Each component must -be owned by root or the service identity, must not be group-writable or -world-writable, and must not be a symbolic link. A root-owned sticky shared -ancestor is the only writable-ancestor exception. Relay fails closed on -non-Unix systems because the same ownership and mode checks are unavailable. - -## Confirm the SQLite source profile - -Confirm that the source profile already selected in the sealed Registry package -matches the deployment. The runtime cannot change it. Return an unsuitable -profile to the data publisher before deployment rather than modifying the -package locally. - -| Property | Snapshot | Live read-only | -| --- | --- | --- | -| Publisher updates while Relay runs | No | Yes, through a separate trusted process | -| File identity and content digest | Captured and enforced | Current path and open-handle identity enforced | -| SQLite journal and write-ahead-log files | Refused | SQLite-managed live state permitted by the contract | -| Per-request consistency | Immutable file | One read transaction | -| List and cursor pagination | Supported | Not supported | -| ETag and cache revalidation | Supported for cacheable public responses | Disabled | -| Source revision | Exact digest | Explicitly unversioned | - -Use snapshot for published extracts and reproducible public Registries. Use -live read-only when a separate trusted publisher must apply compatible updates -without restarting Relay. Live resources support identifier read and named -exact lookup, return `Cache-Control: no-store`, and make no historical -reproducibility claim. - -Both profiles pin the expected SQLite schema fingerprint. Relay verifies it -inside the same transaction as a live read and refuses schema drift, source -replacement, a moved open handle, unsafe snapshot journal files, writes, -unbounded results, and undeclared SQL behavior. - -## Create the runtime file - -Write `/etc/relay/business/runtime.yaml`. The runtime binds local deployment -resources but cannot change the reviewed Registry contract: +The runtime file names local deployment bindings and must not restate or override governed policy: ```yaml apiVersion: relay.registrystack.org/v2alpha1 kind: RelayRuntime -server: - bind: 127.0.0.1:8080 +server: {bind: "127.0.0.1:8080"} packagePath: /etc/relay/business/package -sources: - companies: - path: /srv/registries/business.sqlite -authentication: - issuer: null +sources: {companies: {path: /srv/registries/business.sqlite}} +authentication: {issuer: null} audit: sink: /var/lib/relay/business/audit.jsonl integrityKeyRef: secret:file/secrets/audit-integrity-key -cursor: - integrityKeyRef: secret:file/secrets/cursor-integrity-key - maximumAgeSeconds: 300 -limits: - requestTimeoutMilliseconds: 1500 - concurrentQueries: 32 -quotas: - requestsPerMinute: 10000 - burst: 1000 -shutdown: - gracePeriodMilliseconds: 1000 +cursor: {integrityKeyRef: secret:file/secrets/cursor-integrity-key, maximumAgeSeconds: 300} +limits: {requestTimeoutMilliseconds: 1500, concurrentQueries: 32} +quotas: {requestsPerMinute: 120, burst: 20} ``` -Relative `secret:file/` paths resolve beneath the runtime directory. A secret -manager can instead expose `secret:env/NAME`. Relay never accepts the secret -value itself in the contract or runtime YAML. +`authentication.issuer: null` is valid only when every compiled representation is public. +A package with a protected representation needs the configured issuer at startup. +The issuer's verified claims may establish scopes, purpose, and row authority, but cannot enable an +operation or representation the package did not compile. -Create independent random keys with the institution's secret manager. For a -local Unix deployment, write at least 32 random bytes to each owner-only file -without printing them: +`relay serve --runtime ` opens only the sealed package at `packagePath`. +It rejects unsafe paths, a package inventory mismatch, source-schema drift, missing mandatory audit +inputs, and incompatible runtime bindings before listening. +There is no deployment switch that disables fail-closed audit behavior. -```sh -umask 077 -sudo install -o -g -m 0600 /dev/null \ - /etc/relay/business/secrets/audit-integrity-key -sudo install -o -g -m 0600 /dev/null \ - /etc/relay/business/secrets/cursor-integrity-key -openssl rand 32 | sudo -u tee /etc/relay/business/secrets/audit-integrity-key >/dev/null -openssl rand 32 | sudo -u tee /etc/relay/business/secrets/cursor-integrity-key >/dev/null -sudo chmod 0400 /etc/relay/business/secrets/audit-integrity-key \ - /etc/relay/business/secrets/cursor-integrity-key -``` +Quota configuration has two values for the whole Relay deployment. `requestsPerMinute` sets the +refill rate and `burst` sets the short-term capacity. Relay keeps a separate bucket for each +compiled operation, so one busy operation cannot exhaust another. Every representation of an +operation shares that operation's bucket. Version 1 does not add per-representation, per-client, +or distributed quota modes. Put those controls at the trusted gateway when the deployment needs +them. -Make the completed runtime file root-owned and non-writable: +## Choose the source profile already reviewed -```sh -sudo chown root:root /etc/relay/business/runtime.yaml -sudo chmod 0444 /etc/relay/business/runtime.yaml -``` - -The source identifier `companies` must match the sealed package. Runtime values -cannot change Registry identity, resources, views, properties, operations, -disclosure profiles, access rules, classifications, semantics, or metadata -visibility. - -## Configure protected operations - -Keep `authentication.issuer: null` only when every operation is public. A -package with a protected operation requires one issuer in the runtime. Relay -loads the issuer's OpenID Connect discovery document and public keys during -startup, then verifies a narrow JSON Web Token access-token profile on every -protected request. +The package selects the source profile. +Runtime cannot change it. -The verified token must carry one exact audience, an accepted token type and -algorithm, a trusted key identifier, bounded lifetime, issue and expiry times, -a token identifier, a principal, and the operation scope. Optional purpose and -row authority come from verified scalar claims named by the access rule. HTTP -headers and query parameters cannot create that authority. +| Property | Snapshot | Live read-only | +| --- | --- | --- | +| Publisher updates while Relay runs | No | Yes, through a separate trusted publisher. | +| Source revision | Captured content digest | Explicitly unversioned. | +| List and cursor pagination | Supported | Not supported. | +| Cache revalidation | Available only for eligible public representations | Disabled. | +| Request consistency | Immutable file | One read transaction. | -Registry Mint is one optional issuer for an institution without an existing -authorization server. Relay has no token-issuance route or production runtime -dependency on Mint. The issuer assigns scopes and claims; the sealed package -still defines the maximum operation set. +Both profiles pin the SQLite schema fingerprint and deny writes, arbitrary SQL, schema drift, and +unbounded result behavior. +Live resources support only identifier read and named exact lookup. ## Start and verify the service -Start Relay as `` from the exact runtime path: +Start the process using the exact runtime file: ```sh -sudo -u /usr/local/bin/relay serve \ - --runtime /etc/relay/business/runtime.yaml +sudo -u /usr/local/bin/relay serve --runtime /etc/relay/business/runtime.yaml ``` -Relay stays in the foreground. It logs the private listener only after package, -source, issuer when configured, audit, secret, and readiness checks succeed. -The timestamp is omitted from this abridged log entry: - -```json -{"level":"INFO","fields":{"message":"relay service listening","bind":"127.0.0.1:8080"},"target":"registry_relay_v2::startup"} -``` - -From the same network boundary, check liveness and readiness: +Relay reports its listener only after it verifies the package, source, issuer when configured, +audit sink, secrets, limits, and readiness. +Check the private endpoint from the proxy network boundary: ```sh -relay healthcheck --url http://127.0.0.1:8080/health +curl -fsS http://127.0.0.1:8080/health curl -fsS http://127.0.0.1:8080/ready ``` -The healthcheck exits with status `0`. Readiness returns: +Both return a successful response only after startup completed. +Publish the API through the operator-controlled TLS proxy or ingress, not by exposing the private +listener directly. -```json -{"status":"ready"} -``` +## Retain value-free audit evidence + +For every data operation, including anonymous public access, Relay persists an attempt event before +SQLite access and a terminal event before it releases the exact response bytes. +An audit failure blocks source access or withholds the response. + +Audit binds the Registry, resource, operation, representation, disclosure profile, selected +property identifiers or digest, processing handling, disclosure handling, transform identifiers, +contract revision, and source revision. +It excludes tokens, selectors, raw principals, source values, response values, and raw Record +identifiers. +Keep the audit file and integrity key under one retention and access-control policy. + +Operational logs are also value-free. +Use them for route template, status, latency, and trace correlation, not to recover request or +Registry data. + +## Replace complete revisions + +Relay does not hot-reload, merge, overlay, or fall back across packages. + +1. Receive a newly reviewed package, compatible source, and change report. +2. Install them at new trusted revisioned paths without altering the active revision. +3. Start a candidate on a private listener and wait for readiness. +4. Send a smoke request through the production proxy policy. +5. Shift traffic, drain the previous process, and terminate it. +6. Retain the package, source revision, audit segment, and change review under institutional policy. -`/health` proves the process can answer. `/ready` confirms the loaded service -state remains ready. Publish only the intended API routes through the -operator-controlled TLS proxy or ingress. - -## Retain audit and operational logs - -Every data operation, including anonymous public access, writes a durable -attempt before SQLite access and a terminal event before response bytes are -released. If audit fails, Relay blocks source access or discards the held -response instead of releasing an unaudited result. - -Audit events identify the Registry, resource, operation, processing -description, access-rule revision, purpose when present, row-boundary kind, -disclosure profile, selected property identifiers or digest, handling level, -contract revision, and source revision. They exclude tokens, selectors, source -values, response values, and raw subject identifiers. Protect the audit path -and integrity key as one retention boundary. - -Relay writes JSON lifecycle and request-outcome logs to standard error. Request -outcomes contain only method, route template, status, latency, and trace -identifier. They exclude request paths, identifiers, query values, headers, -bodies, selectors, and principals. `RELAY_LOG` accepts `off`, `error`, `warn`, -`info`, `debug`, or `trace` for Relay-owned targets. Derive operational metrics -from these fixed dimensions outside the process. - -## Deploy a new revision - -Relay does not hot-reload or merge packages. Replace one complete revision at a -time: - -1. Receive a newly reviewed package, matching source, and approved change - report from the data publisher. -2. Install them at new revisioned paths without modifying the active package. -3. Start a candidate process on a private listener and wait for `/ready`. -4. Send one authorized smoke request through the same proxy policy used in - production. -5. Shift traffic to the candidate. -6. Drain the previous process and send `SIGTERM`. -7. Retain the package revision, source revision, audit segment, and change - review according to institutional policy. - -Rollback activates a previously reviewed package with its compatible source -and runtime bindings. Relay never falls back to another interpretation after a -startup failure. - -## Verify the deployment - -Before accepting traffic, confirm: - -- The listener appears only after the startup checks complete. -- `/health` and `/ready` succeed from the proxy's network boundary. -- One public or authorized request returns the expected Registry identifier, - resource, and contract revision. -- The audit sink contains the matching attempt and terminal events. -- Process logs contain no request values, tokens, selectors, or Registry data. -- `SIGTERM` records a complete graceful shutdown in a staging run. - -## Next - -- [Author a Registry Relay project](../../configure/relay/) for contract and - change-review responsibilities. -- [Understand Relay's product boundary](../../explanation/governed-registry-publication/) - for the one-Registry trust model. -- [Review semantics, classification, and disclosure](../../explanation/relay-semantics-and-disclosure/) - for artifact visibility and field minimization. +Rollback activates a complete prior package only with its compatible source and runtime bindings. ## Troubleshooting | Symptom | Cause | Fix | | --- | --- | --- | -| Startup refuses an unsafe path | A path component is a symlink, has the wrong owner, or is writable by group or world | Move the deployment to trusted Unix paths and correct ownership and modes before retrying. | -| Startup reports a schema mismatch | The SQLite structure differs from the packaged fingerprint | Stop deployment and return the source and contract to the authoring change workflow. | -| Startup reports the issuer is not ready | Discovery, keys, issuer identity, or network policy does not match the runtime | Correct the issuer deployment; do not disable authentication for a protected package. | -| `/health` works but `/ready` fails | A loaded source, issuer, audit sink, or service dependency is no longer ready | Keep the service out of rotation and repair the failing dependency. | -| A response is withheld after a successful query | The terminal audit write failed | Restore the audit sink and verify its chain before accepting traffic. | -| A previous package will not start during rollback | Its source or runtime binding is no longer compatible | Restore the complete reviewed package, source, and runtime set rather than mixing revisions. | +| Startup refuses a path | A component is a symlink, has the wrong owner, or is writable | Move the deployment to trusted Unix paths and correct ownership and modes. | +| Startup reports a package or schema error | The package, source, or review revision does not match | Stop deployment and return the change to the authoring workflow. | +| A protected representation is denied | The issuer or token does not satisfy that representation's exact access rule | Correct the issuer or caller authority. Do not expose a weaker representation as fallback. | +| A response is withheld | The terminal audit write failed | Restore the audit sink and verify its integrity before accepting traffic. | diff --git a/docs/site/src/content/docs/tutorials/publish-governed-sqlite-registry.mdx b/docs/site/src/content/docs/tutorials/publish-governed-sqlite-registry.mdx index 057e12d66..a6e6a3eec 100644 --- a/docs/site/src/content/docs/tutorials/publish-governed-sqlite-registry.mdx +++ b/docs/site/src/content/docs/tutorials/publish-governed-sqlite-registry.mdx @@ -1,6 +1,6 @@ --- title: Publish a governed SQLite registry -description: Run the supplied business Registry, verify its contract, and request a minimized Record through Registry Relay. +description: Run the supplied business Registry project, verify its contract, and test a minimized Record response through Registry Relay. status: draft owner: registry-docs source_repos: @@ -21,12 +21,12 @@ standards_referenced: import QuickstartMeta from '../../../components/QuickstartMeta.astro'; Run a synthetic business Registry through Registry Relay, from a reviewed -SQLite view to a working read-only API. You will verify the supplied contract, +SQLite view to a verified read-only API package. You will verify the supplied contract, generate its documentation and semantic model, seal a deployment package, and -request only two permitted properties from the running service. +replay a request for only two permitted properties through the Relay router. -relay ``` -Both commands now use the same contract compiler. `relayctl` prepares and -checks a project; `relay` opens the checked package and serves its HTTP API. +`relayctl` uses the same contract compiler and HTTP router as the `relay` service while checking, +generating, packaging, and replaying the synthetic project. ## Prepare the sample Registry @@ -104,6 +101,7 @@ expected-http.yaml expected requests and responses codelists/ reviewed controlled values governance/ legal-basis and identifier-lifecycle records semantics/ optional mappings to external vocabularies +reports/ accepted generated-review evidence when applicable ``` The Registry contract is the reviewed agreement about what the API means and @@ -134,8 +132,8 @@ diagnostics: ``` This is the first governed result. Production checks refuse unreviewed -semantic or classification suggestions, missing source bindings, schema -changes, and inconsistent access or disclosure rules. +semantic or classification suggestions, missing or stale classification-review +evidence, source-schema changes, and inconsistent access or disclosure rules. Generate the public and operator artifacts: @@ -188,7 +186,10 @@ relayctl test Generated files include OpenAPI 3.1, JSON Schema, Shapes Constraint Language (SHACL), JSON for Linked Data (JSON-LD), codelist schemas, processing -descriptions, and capability discovery. +descriptions, capability discovery, and deterministic value-free reports under +`generated/reports/` and `generated/governance/`. The reports distinguish +source-column processing handling from output-property disclosure handling. +They are review inputs, not automatic approvals. ## Seal the deployment package @@ -218,134 +219,63 @@ relayctl package The package also inventories governed-file digests, media types, and artifact visibility. Relay verifies that inventory before opening the database, audit -file, token issuer, or listener. +file, token issuer, or listener. It starts from this complete package only and +does not reload, merge, or fall back to a different interpretation. -## Start Relay +## Verify the minimized request boundary -Create disposable integrity keys without printing their values, then start the -service: - -```sh -export RELAY_TEST_AUDIT_KEY="$(python3 -c 'import secrets; print(secrets.token_hex(32))')" -export RELAY_TEST_CURSOR_KEY="$(python3 -c 'import secrets; print(secrets.token_hex(32))')" -relay serve --runtime "$project/runtime.yaml" -``` - -Leave this terminal running after the listener message. The timestamp is -omitted from this abridged log entry: - -```json -{"level":"INFO","fields":{"message":"relay service listening","bind":"127.0.0.1:18082"},"target":"registry_relay_v2::startup"} -``` - -This Registry is intentionally public, so the local run needs no access -token. A protected deployment configures one OpenID Connect token issuer in -the runtime file. - -## Read the Registry metadata - -In a second terminal, confirm that the service is ready: - -```sh -curl -fsS http://127.0.0.1:18082/ready -``` - -```json -{"status":"ready"} -``` - -Now read the service document: +The supplied `filtered-page` fixture requests active registrations, applies the declared +jurisdiction filter, and narrows `domainData` to `registrationNumber` and `legalName`. +Run that one case again through the same Relay router used by the service: ```sh -curl -fsS http://127.0.0.1:18082/v2 | python3 -m json.tool +relayctl test "$project" --fixture filtered-page ``` -The document identifies one Registry, its accountable Authority and technical -operator, and two available read capabilities. This is an abridged response: +The report contains one successful step with `actualStatus: 200` and `passed: true`. +The fixture expectation also requires complete Registry Core context and exactly the two requested +domain properties. An unknown field, SQLite column, sort, or undeclared filter is a separate +refusal case in the full journey and cannot change the compiled query. -```json -{ - "registryIdentifier": "urn:example:registry:registered-businesses", - "name": "Synthetic registered business Registry", - "capabilities": [ - {"operationIdentifier": "registered-business.list", "pattern": "list"}, - {"operationIdentifier": "registered-business.read", "pattern": "retrieve"} - ] -} -``` - -Relay derives this inventory from the operations that passed compilation. The -project does not maintain a second capability list that can drift from the API. - -## Request fewer properties - -Request active registrations and narrow `domainData` to two governed -properties: - -```sh -curl -fsS \ - 'http://127.0.0.1:18082/v2/resources/registered-business/records?status=ACTIVE&fields=registrationNumber,legalName&pageSize=4' \ - | python3 -m json.tool -``` - -Each item keeps the mandatory record context while `domainData` contains only -the requested subset: - -```json -{ - "items": [ - { - "registryIdentifier": "urn:example:registry:registered-businesses", - "recordIdentifier": "BIZ-SYNTH-0001", - "revisionIdentifier": "7", - "lifecycleState": "ACTIVE", - "authorityIdentifier": "urn:example:institution:company-registrar", - "recordedAt": "2026-06-01T08:00:00Z", - "domainData": { - "registrationNumber": "BIZ-SYNTH-0001", - "legalName": "Example Orchard Cooperative" - } - } - ], - "pageInfo": {"nextCursor": null} -} -``` +The contract also defines a protected `registrar` representation for the same read and list +operations. The full fixture journey proves its distinct scope, no-store cache posture, a denied +request, and an unknown representation. Relay authorizes the requested representation exactly and +never falls back to the public default. -The response is abridged. The complete item links to the schema and semantic -model that describe this representation. Response metadata records the -operation, disclosure profile, selected fields, contract revision, and source -revision. An unknown field, SQLite column name, sort, or undeclared filter -receives a bounded problem response instead of changing the query. +Starting the packaged service requires the configured token issuer to be reachable because the +package contains protected representations, even when the request you plan to send is public. +Continue with [Operate Registry Relay](../../operate/relay/) to bind a real institutional issuer, +trusted paths, audit retention, and a private listener. -## Stop the service +## Remove the disposable project -Return to the Relay terminal and press `Ctrl+C`. Relay logs `relay shutdown -complete` after the graceful shutdown. Then remove the disposable project and -clear the local keys: +Return to the repository root and remove the tutorial copy: ```sh cd "$repo_root" rm -rf -- "$reader_root" -unset RELAY_TEST_AUDIT_KEY RELAY_TEST_CURSOR_KEY ``` The cleanup commands print nothing when they succeed. ## What you built -- One reviewed contract produced the API, semantic artifacts, disclosure - rules, and capability inventory. +- One reviewed contract produced the API package, semantic artifacts, disclosure rules, and + capability inventory. - SQLite tables and columns did not become routes by convention. +- Generated identification and classification material remained suggestions + until the governed review accepted it. - The caller selected fewer properties but could not select more. - Mandatory Registry and Record context remained present in every result. -- Relay verified the sealed package before opening its source or listener. +- The shared Relay router verified the public, protected, and refusal behavior against synthetic + source data. ## Next - [Understand Relay's product boundary](../../explanation/governed-registry-publication/) to decide whether Relay fits an institutional Registry. - [Review semantics, classification, and disclosure](../../explanation/relay-semantics-and-disclosure/) - before assigning public names and handling levels to real fields. + before assigning published names and handling levels to real fields. - [Author a Registry Relay project](../../configure/relay/) to bind an institution-owned SQLite view. - [Operate Registry Relay](../../operate/relay/) to prepare source files, @@ -358,5 +288,3 @@ The cleanup commands print nothing when they succeed. | `cargo build` cannot find a package | The command is outside the Registry Stack root or the checkout predates Relay | Return to the source root and confirm that `crates/registry-relay-v2` exists. | | Relay refuses a path containing `/tmp` | On macOS, `/tmp` is a symlink | Keep the `pwd -P` assignment so the runtime receives the physical path. | | `relayctl check` returns diagnostics | A governed input or SQLite schema does not match the reviewed contract | Read the diagnostic identifiers, fix the authored input, and run `check --production` again. | -| `relay serve` reports that the address is in use | Another process is listening on port `18082` | Stop that process or change the sample listener consistently before packaging. | -| `/ready` is unavailable | Startup refused the package, source, audit path, keys, or listener | Read the value-free startup error in the Relay terminal and correct that deployment input. | diff --git a/products/relay-v2/CONCEPT.md b/products/relay-v2/CONCEPT.md index 171dd3d2c..80fde284c 100644 --- a/products/relay-v2/CONCEPT.md +++ b/products/relay-v2/CONCEPT.md @@ -1,7 +1,7 @@ # Relay V2 Product Concept Status: Approved product direction -Date: 2026-08-09 +Date: 2026-08-10 Basis: Registry Stack `origin/main` at the start of the Relay V2 exploration Directional inputs: the written GovStack Digital Registries specification and @@ -222,6 +222,70 @@ for serving a valid Registry or a runtime policy input. DPV remains a semantic projection, not Relay's policy language. Relay executes its small typed access contract, never arbitrary RDF, DPV rules, ODRL, or remote vocabulary content. DPV 2.3 is a W3C Community Group report rather than a W3C Recommendation, so the profile and vocabulary digest must be pinned and upgraded deliberately. +### Schema-only identification and reviewed representation governance + +Relay keeps one classification model. `semanticTerm` describes meaning; +`privacy`, `institutional`, and `handling` describe the Registry Authority's +governance context; `status` is `suggested`, `uncertain`, or `reviewed`; and +`provenanceRef` identifies the review evidence. Identification proposes +candidates for that model. It neither changes runtime authorization nor creates +a second generic tag model. + +`relayctl inspect` and `relayctl generate` identify only from observed SQLite +schema, declared types, key metadata, codelist bindings, authored roles, and a +digest-pinned embedded core rule pack. They do not read source values. A +candidate records its source and view, source column, suggested property, +semantic term and technical role, privacy candidates, matched rule identifiers +and versions, pack identity, categorical confidence (`exact`, `strong`, +`weak`, or `conflict`), and non-reviewed status. A conflict is `uncertain`; a +generated result never self-approves. + +Generation writes deterministic, value-free review inputs beneath its output +directory at these fixed paths: + +- `generated/reports/identification-report.json` +- `generated/reports/classification-inventory.json` +- `generated/reports/representation-report.json` +- `generated/reports/contextual-review-findings.json` +- `generated/governance/classification-review-starter.yaml` + +After review, a generated project copies the accepted report to +`reports/identification-report.json`. The governed reviewed input is +`governance/classification-review.yaml`, named +by the existing `classifications.provenanceRef`. Its closed sidecar has +`apiVersion: relay.registrystack.org/classification-review/v1`, +`kind: ClassificationReview`, `registryIdentifier`, +`classificationInventoryDigest`, `method` (`generated`, `imported`, or +`manual`), `reviewer`, `reviewDate`, `status`, and `rationaleRef`. A +`generated` review also has `generatedIdentification` with `reportRef`, +`reportDigest`, and `rulePack: {id, version, digest}`. Production compilation +refuses a missing, non-reviewed, stale, or digest-mismatched sidecar. Manual +and imported review are first-class and do not require a generated report. A +relevant contract, schema, source-column, or classification change invalidates +review; a rule-pack change does so only when that pack informed it. + +Every property has its own output classification. Every processed source +column has its own reviewed source-column classification. The compiler derives +processing handling from every Registry Core, output, transform input, +selector, filter, order, and row-binding column, and disclosure handling from +properties serializable by the representation. Source processing controls, +authentication, audit, and cache use the processing floor even when a reviewed +output has lower disclosure handling. Anonymous publication cannot transform a +non-public source: a public representation must read a reviewed pre-derived +public SQLite view column. + +Only two finite deterministic transforms are in this profile. `partial-string` +accepts a reviewed string and emits Relay's fixed `***` marker plus a bounded +Unicode-scalar prefix or suffix; a value no longer than the configured reveal +length emits only `***`. `date-precision` accepts a reviewed canonical `date` +or `date-time` and emits `year` or `year-month`. Null, incompatible, +non-canonical, or oversized required inputs fail closed without values; an +optional null omits the property. A transformed value has its own property, +semantic term, datatype, and classification. Hashing, pseudonyms, encryption, +regular-expression replacement, caller-defined masks or expressions, +geographic and numeric transforms, codelist remapping, and any dynamic policy +engine are not part of this release. + ### Registry operations and safe requester minimization A resource compiles only the operations its publisher declares: collection listing, identifier read, and named exact lookup. A resource may expose any appropriate subset. An exact-lookup-only resource compiles no enumeration or identifier-read operation. @@ -230,16 +294,16 @@ Collection queries use publisher-defined, typed, camelCase filter parameters directly in the query string, for example `status=ACTIVE`. Version one supports exact equality. Any non-empty subset of declared filters is valid; the contract separately declares whether an unfiltered request is allowed. `pageSize`, -`cursor`, and `fields` are reserved names. Filters in query strings are +`cursor`, `fields`, and `representation` are reserved names. Filters in query strings are limited to non-personal selectors. Relay binds their values as SQL parameters. Callers cannot introduce source columns, joins, operators, expressions, arbitrary sorting, or SQL. Named exact lookups define their complete required inputs, row boundary, result shape, and maximum of one result. Sensitive selectors belong in a bounded request body rather than a URL. Lookup outcomes are deliberately non-enumerating and are subject to tighter limits and audit. -The disclosure profile's `properties` list is both the maximum and the default -property set in Version one. A caller may request a non-empty subset of those -published properties, or receive the complete list when no subset is requested. +The selected representation's disclosure profile supplies the maximum property +set. A caller may request a non-empty subset of those published properties, or +receive the complete selected profile when no subset is requested. This is a one-way minimization control: - it can remove top-level data properties but never add a property, select a source column, change a derivation, or bypass a row boundary; @@ -251,15 +315,16 @@ This is a one-way minimization control: never serialized. Physical column-read minimization is an optimization, not a Version one correctness contract. -This is not dynamic attribute authorization. Version one has one reviewed -maximum disclosure profile per operation. Different operations may use -different profiles, but one operation does not select a different maximum from -the caller's identity or scopes. Supporting different entitlements for two -consumers of the same operation is a documented future gap. Within the -authorized profile, requester-selected fields can only disclose less, so a -valid subset requires no additional field-level authorization decision. It -never lowers the operation's compiled handling level, authentication, audit, -quota, metadata, or cache posture. +This is not dynamic attribute authorization. An operation has a finite ordered +map of reviewed representations, exactly one `defaultRepresentation`, and one +access rule plus one disclosure profile per representation. An absent +`representation` selects that sole declared default. A supplied representation +is authorized exactly as requested: denial, an invalid bearer, or an unknown +identifier never falls back to another profile. Within the selected profile, +requester-selected `fields` can only disclose less and never lower the +operation's compiled handling level, authentication, audit, quota, metadata, +or cache posture. Caller-dependent or tag-derived profiles remain out of +scope. ### HTTP contract @@ -272,9 +337,9 @@ GET /openapi.json GET /v2 GET /v2/resources?pageSize=...&cursor=... GET /v2/resources/{resource} -GET /v2/resources/{resource}/records?pageSize=...&cursor=...&status=...&fields=... -GET /v2/resources/{resource}/records/{recordIdentifier}?fields=... -POST /v2/resources/{resource}/lookups/{lookup}?fields=... +GET /v2/resources/{resource}/records?pageSize=...&cursor=...&status=...&representation=...&fields=... +GET /v2/resources/{resource}/records/{recordIdentifier}?representation=...&fields=... +POST /v2/resources/{resource}/lookups/{lookup}?representation=...&fields=... GET /v2/artifacts/{artifactIdentifier} ``` @@ -296,8 +361,10 @@ Lists use `pageSize`, `cursor`, and the envelope `{items, pageInfo: {nextCursor}, meta}`. `nextCursor` is nullable. Ordering is contract-defined with the Record identifier as a unique tie-breaker. The opaque authenticated cursor binds the contract and source revisions, operation, -filters, order, selected fields, authorization context, and expiry. Every page -is reauthorized. Callers cannot choose an order. +selected representation and disclosure profile, transform inventory, filters, +order, selected fields, authorization context, and expiry. Every page is +reauthorized. Callers cannot choose an order or replay a cursor across +representations. Single-record reads and resolved lookups use `{data, meta}`. `data` contains the Registry Core context and `domainData`. `fields` is a documented Relay @@ -305,15 +372,18 @@ extension: a non-empty, duplicate-free comma-separated list of public property keys. A property key is the contract's URL-safe camelCase name, not a source column or semantic IRI. Exactly one `fields` parameter is accepted; empty members, whitespace, repeats, and duplicate keys are invalid. It only narrows -`domainData`; Registry Core context cannot be removed, and response ordering -remains contract-defined rather than request-defined. +the selected representation's `domainData`; Registry Core context cannot be +removed, and response ordering remains contract-defined rather than +request-defined. A field outside the selected representation is rejected +before source access. Ordinary JSON is the default. `application/ld+json` adds the generated context and a derived global `@id` while preserving all Registry Core identifiers and the same selected domain values. Responses vary on `Accept`; unsupported representations receive `406`. Where caching is allowed, the strong ETag hashes -the exact representation bytes, including the -field subset, and supports `If-None-Match` with `304`. Every cacheable public +the exact representation bytes, including the selected representation and field +subset, and supports `If-None-Match` with `304`. Only a public representation +with a public processing floor over a snapshot may be cacheable. Every cacheable public response includes `Vary: Accept, Authorization` so an anonymous `200` cannot satisfy a request carrying an invalid bearer. Non-public and unversioned-live responses are `no-store` and emit no ETag. @@ -382,7 +452,7 @@ Purpose comes from, or is constrained by, verified authority. A caller header ne The resource posture and contract define the maximum compiled operation set. Token scopes can only narrow it. Separate scopes for list, read, and named lookup allow an issuer to give a client exact-lookup access without collection or identifier-read access. Conversely, no token can enable an operation the resource did not compile. Relay does not maintain a client registry; the trusted issuer registers clients and assigns scopes. -Each request produces a typed access decision followed by a typed disclosure plan. The plan contains the authorized operation, row constraints, maximum disclosure profile, and any requester-selected property subset. This architectural seam keeps authentication, authorization, row constraints, query construction, and serialization separate. Version one uses static reviewed disclosure plans and does not require a general PDP, CEL, dynamic masking, per-client field permissions, or tag-based ABAC. +Each request produces a typed access decision followed by a typed disclosure plan. The plan contains the authorized operation and representation, row constraints, selected disclosure profile, and any requester-selected property subset. This architectural seam keeps authentication, authorization, row constraints, query construction, and serialization separate. Version one uses static reviewed disclosure plans and does not require a general PDP, CEL, dynamic masking, per-client field permissions, or tag-based ABAC. ### Token issuers and optional Registry Mint @@ -655,7 +725,9 @@ shape. The generated schema makes their constraints precise. - Registry Core context is mandatory and requester field selection can narrow only `domainData`. - Lists use `pageSize`, `cursor`, `items`, and `pageInfo.nextCursor`; predefined filters are direct camelCase equality parameters. - Named exact lookup remains a bounded POST action and maps to constrained Consultation Search, not Record Match. -- One reviewed maximum disclosure profile exists per operation. Caller-dependent entitlement variants are deferred. +- Each operation has finite reviewed representations, an explicit sole default, + and representation-owned access plus disclosure. Dynamic, caller-derived + entitlement variants are deferred. - Handling levels are `public`, `internal`, `confidential`, and `restricted`; purpose and row binding are separate explicit constraints. - Snapshot SQLite is valuable but optional. Unversioned live sources are `no-store` and do not compile paginated lists. - A deployment configures at most one issuer in Version one. Responses are unsigned. Registry Mint is optional, never a Relay runtime dependency, and may be paired when it emits the same standard token profile. @@ -664,7 +736,8 @@ shape. The generated schema makes their constraints precise. ## Deliberate future gaps -- different maximum disclosure entitlements for two consumers of the same operation; +- caller-derived maximum disclosure entitlements, tag-based ABAC, external PDP, + or per-profile quotas; - publisher-owned live revisions, live pagination, and live caching; - multi-issuer selection and a frozen `relay`/`relayctl` subprocess protocol; - Registry Manifest, DPV, safeguards, and machine-readable GovStack alignment projections; diff --git a/products/relay-v2/CONFIGURATION-EXAMPLES.md b/products/relay-v2/CONFIGURATION-EXAMPLES.md index 915693e23..88c26ed85 100644 --- a/products/relay-v2/CONFIGURATION-EXAMPLES.md +++ b/products/relay-v2/CONFIGURATION-EXAMPLES.md @@ -1,7 +1,7 @@ # Relay V2 Configuration Examples Status: Illustrative design probes -Date: 2026-08-09 +Date: 2026-08-10 Product direction: [Relay V2 Product Concept](CONCEPT.md) Acceptance boundary: [Relay V2 Definition of Done](DEFINITION-OF-DONE.md) @@ -22,7 +22,7 @@ The intended boundaries are firmer than the syntax: - every Record has mandatory Registry Core bindings in addition to selectable domain properties; - `sourceRequired` governs complete source-Record validation, while the public representation schema permits any compiled selectable `domainData` subset; - external semantic alignment is optional and file-based, pinned, and reviewed; -- every operation chooses a maximum disclosure profile, and the requester may only select fewer properties; +- every operation declares one default and a finite ordered set of representations; an operation with any public representation uses a public default; access and disclosure belong to the representation, while the requester may only select fewer properties within the chosen representation; - token issuers assign authority, but they cannot enable an operation Relay did not compile; - family capabilities are derived from compiled operations, never duplicated in configuration; - none of these examples enables response signing. @@ -130,6 +130,7 @@ resources: lifecycleState: {sourceColumn: lifecycle_state, codelist: codelists/record-lifecycle.yaml} recordedAt: {sourceColumn: recorded_at} sourceColumnClassifications: + enrolment_reference: {privacy: identifying, institutional: restricted, handling: restricted, status: reviewed} record_revision: {privacy: derived} lifecycle_state: {privacy: personal-context} recorded_at: {privacy: personal-context} @@ -146,6 +147,15 @@ resources: sourceRequired: true semanticTerm: local:enrolmentReference classification: {privacy: identifying} + maskedEnrolmentReference: + label: Masked enrolment reference + description: Relay-owned partial-string view that exposes only the final four Unicode scalars + sourceColumn: enrolment_reference + transform: {kind: partial-string, reveal: suffix, characters: 4} + type: string + sourceRequired: true + semanticTerm: local:maskedEnrolmentReference + classification: {privacy: partially-revealed-identifying, institutional: confidential, handling: confidential, status: reviewed} programmeCode: label: Programme code description: Reviewed code for the assistance programme @@ -192,26 +202,33 @@ resources: classification: {privacy: personal-context, institutional: internal} disclosureProfiles: - consultation: + limited: + properties: [maskedEnrolmentReference, enrolmentStatus, validThrough] + caseworker: properties: [enrolmentReference, programmeCode, enrolmentStatus, entitlementCategory, validThrough, serviceOfficeCode] operations: lookups: - id: by-case-and-person - access: - scope: registry:social-assistance:lookup - purpose: - claim: purpose - allowed: [benefit-delivery] - authorityRowBinding: - claim: service_area - sourceColumn: service_area_code requestBody: maximumBytes: 512 selectors: caseReference: {sourceColumn: case_reference, type: string, minimumBytes: 8, maximumBytes: 96} personReference: {sourceColumn: person_reference, type: string, minimumBytes: 8, maximumBytes: 96} - disclosureProfile: consultation + defaultRepresentation: limited + representations: + limited: + access: + scope: registry:social-assistance:limited + purpose: {claim: purpose, allowed: [benefit-delivery]} + authorityRowBinding: {claim: service_area, sourceColumn: service_area_code} + disclosureProfile: limited + caseworker: + access: + scope: registry:social-assistance:caseworker + purpose: {claim: purpose, allowed: [benefit-delivery]} + authorityRowBinding: {claim: service_area, sourceColumn: service_area_code} + disclosureProfile: caseworker processingDescriptions: - id: benefit-delivery-consultation @@ -344,11 +361,19 @@ resources: legalName: label: Legal name description: Current registered legal name of the business - sourceColumn: legal_name + sourceColumn: registrar_legal_name type: string sourceRequired: true semanticTerm: local:legalName classification: {privacy: potentially-personal, institutional: public-by-law} + registrarLegalName: + label: Registrar legal name + description: Protected authoritative legal name for registrar work + sourceColumn: legal_name + type: string + sourceRequired: true + semanticTerm: local:registrarLegalName + classification: {privacy: potentially-personal, institutional: public-by-law, handling: confidential, status: reviewed} registrationStatus: label: Registration status description: Current lifecycle status of the business registration @@ -386,11 +411,15 @@ resources: disclosureProfiles: public-register: properties: [registrationNumber, legalName, registrationStatus, legalForm, registeredJurisdiction, registeredOfficeArea] + registrar-register: + properties: [registrationNumber, registrarLegalName, registrationStatus, legalForm, registeredJurisdiction] operations: list: - access: public - disclosureProfile: public-register + defaultRepresentation: public-register + representations: + public-register: {access: public, disclosureProfile: public-register} + registrar: {access: {scope: registry:business:list-registrar}, disclosureProfile: registrar-register} filters: - {name: status, property: registrationStatus, type: controlled-code} - {name: jurisdiction, property: registeredJurisdiction, type: controlled-code} @@ -398,8 +427,10 @@ resources: orderBy: [registrationNumber] pagination: {defaultPageSize: 50, maximumPageSize: 200} read: - access: public - disclosureProfile: public-register + defaultRepresentation: public-register + representations: + public-register: {access: public, disclosureProfile: public-register} + registrar: {access: {scope: registry:business:read-registrar}, disclosureProfile: registrar-register} processingDescriptions: - id: statutory-publication @@ -554,6 +585,15 @@ resources: sourceRequired: true semanticTerm: local:registrationDate classification: {privacy: personal} + registrationYear: + label: Registration year + description: Reviewed year-precision form of the registration date + sourceColumn: registration_date + transform: {kind: date-precision, sourceType: date, precision: year} + type: year + sourceRequired: true + semanticTerm: local:registrationYear + classification: {privacy: derived, institutional: confidential, handling: confidential, status: reviewed} registrationArea: label: Registration area description: Administrative area responsible for the registration @@ -576,35 +616,41 @@ resources: registrar-record: properties: [eventReference, eventType, registrationStatus, registrationDate, registrationArea, certificateAvailable] verification-result: - properties: [eventReference, eventType, registrationStatus, certificateAvailable] + properties: [eventReference, eventType, registrationStatus, registrationDate, certificateAvailable] + supervisory-verification: + properties: [eventReference, eventType, registrationStatus, registrationYear, certificateAvailable] operations: read: - access: - scope: registry:civil-events:read - purpose: - claim: purpose - allowed: [civil-registration-administration] - authorityRowBinding: - claim: jurisdiction - sourceColumn: jurisdiction_code - disclosureProfile: registrar-record + defaultRepresentation: registrar + representations: + registrar: + access: + scope: registry:civil-events:read + purpose: {claim: purpose, allowed: [civil-registration-administration]} + authorityRowBinding: {claim: jurisdiction, sourceColumn: jurisdiction_code} + disclosureProfile: registrar-record lookups: - id: verify-registration - access: - scope: registry:civil-events:lookup - purpose: - claim: purpose - allowed: [registration-verification] - authorityRowBinding: - claim: jurisdiction - sourceColumn: jurisdiction_code requestBody: maximumBytes: 384 selectors: registrationNumber: {sourceColumn: registration_number, type: string, minimumBytes: 12, maximumBytes: 96} eventType: {sourceColumn: event_type, type: controlled-code, codelist: codelists/civil-event-types.yaml} - disclosureProfile: verification-result + defaultRepresentation: registrar-verification + representations: + registrar-verification: + access: + scope: registry:civil-events:lookup + purpose: {claim: purpose, allowed: [registration-verification]} + authorityRowBinding: {claim: jurisdiction, sourceColumn: jurisdiction_code} + disclosureProfile: verification-result + supervisory: + access: + scope: registry:civil-events:supervisory + purpose: {claim: purpose, allowed: [registration-supervision]} + authorityRowBinding: {claim: jurisdiction, sourceColumn: jurisdiction_code} + disclosureProfile: supervisory-verification processingDescriptions: - id: registrar-administration @@ -720,9 +766,8 @@ resources[].disclosureProfiles.*.properties[] resources[].id resources[].operations resources[].operations.list -resources[].operations.list.access resources[].operations.list.allowUnfiltered -resources[].operations.list.disclosureProfile +resources[].operations.list.defaultRepresentation resources[].operations.list.filters resources[].operations.list.filters[] resources[].operations.list.filters[].name @@ -733,19 +778,65 @@ resources[].operations.list.orderBy[] resources[].operations.list.pagination resources[].operations.list.pagination.defaultPageSize resources[].operations.list.pagination.maximumPageSize +resources[].operations.list.representations +resources[].operations.list.representations.public-register +resources[].operations.list.representations.public-register.access +resources[].operations.list.representations.public-register.disclosureProfile +resources[].operations.list.representations.registrar +resources[].operations.list.representations.registrar.access +resources[].operations.list.representations.registrar.access.authorityRowBinding +resources[].operations.list.representations.registrar.access.purpose +resources[].operations.list.representations.registrar.access.scope +resources[].operations.list.representations.registrar.disclosureProfile resources[].operations.lookups resources[].operations.lookups[] -resources[].operations.lookups[].access -resources[].operations.lookups[].access.authorityRowBinding -resources[].operations.lookups[].access.authorityRowBinding.claim -resources[].operations.lookups[].access.authorityRowBinding.sourceColumn -resources[].operations.lookups[].access.purpose -resources[].operations.lookups[].access.purpose.allowed -resources[].operations.lookups[].access.purpose.allowed[] -resources[].operations.lookups[].access.purpose.claim -resources[].operations.lookups[].access.scope -resources[].operations.lookups[].disclosureProfile +resources[].operations.lookups[].defaultRepresentation resources[].operations.lookups[].id +resources[].operations.lookups[].representations +resources[].operations.lookups[].representations.caseworker +resources[].operations.lookups[].representations.caseworker.access +resources[].operations.lookups[].representations.caseworker.access.authorityRowBinding +resources[].operations.lookups[].representations.caseworker.access.authorityRowBinding.claim +resources[].operations.lookups[].representations.caseworker.access.authorityRowBinding.sourceColumn +resources[].operations.lookups[].representations.caseworker.access.purpose +resources[].operations.lookups[].representations.caseworker.access.purpose.allowed +resources[].operations.lookups[].representations.caseworker.access.purpose.allowed[] +resources[].operations.lookups[].representations.caseworker.access.purpose.claim +resources[].operations.lookups[].representations.caseworker.access.scope +resources[].operations.lookups[].representations.caseworker.disclosureProfile +resources[].operations.lookups[].representations.limited +resources[].operations.lookups[].representations.limited.access +resources[].operations.lookups[].representations.limited.access.authorityRowBinding +resources[].operations.lookups[].representations.limited.access.authorityRowBinding.claim +resources[].operations.lookups[].representations.limited.access.authorityRowBinding.sourceColumn +resources[].operations.lookups[].representations.limited.access.purpose +resources[].operations.lookups[].representations.limited.access.purpose.allowed +resources[].operations.lookups[].representations.limited.access.purpose.allowed[] +resources[].operations.lookups[].representations.limited.access.purpose.claim +resources[].operations.lookups[].representations.limited.access.scope +resources[].operations.lookups[].representations.limited.disclosureProfile +resources[].operations.lookups[].representations.registrar-verification +resources[].operations.lookups[].representations.registrar-verification.access +resources[].operations.lookups[].representations.registrar-verification.access.authorityRowBinding +resources[].operations.lookups[].representations.registrar-verification.access.authorityRowBinding.claim +resources[].operations.lookups[].representations.registrar-verification.access.authorityRowBinding.sourceColumn +resources[].operations.lookups[].representations.registrar-verification.access.purpose +resources[].operations.lookups[].representations.registrar-verification.access.purpose.allowed +resources[].operations.lookups[].representations.registrar-verification.access.purpose.allowed[] +resources[].operations.lookups[].representations.registrar-verification.access.purpose.claim +resources[].operations.lookups[].representations.registrar-verification.access.scope +resources[].operations.lookups[].representations.registrar-verification.disclosureProfile +resources[].operations.lookups[].representations.supervisory +resources[].operations.lookups[].representations.supervisory.access +resources[].operations.lookups[].representations.supervisory.access.authorityRowBinding +resources[].operations.lookups[].representations.supervisory.access.authorityRowBinding.claim +resources[].operations.lookups[].representations.supervisory.access.authorityRowBinding.sourceColumn +resources[].operations.lookups[].representations.supervisory.access.purpose +resources[].operations.lookups[].representations.supervisory.access.purpose.allowed +resources[].operations.lookups[].representations.supervisory.access.purpose.allowed[] +resources[].operations.lookups[].representations.supervisory.access.purpose.claim +resources[].operations.lookups[].representations.supervisory.access.scope +resources[].operations.lookups[].representations.supervisory.disclosureProfile resources[].operations.lookups[].requestBody resources[].operations.lookups[].requestBody.maximumBytes resources[].operations.lookups[].requestBody.selectors @@ -756,16 +847,22 @@ resources[].operations.lookups[].requestBody.selectors.*.minimumBytes resources[].operations.lookups[].requestBody.selectors.*.sourceColumn resources[].operations.lookups[].requestBody.selectors.*.type resources[].operations.read -resources[].operations.read.access -resources[].operations.read.access.authorityRowBinding -resources[].operations.read.access.authorityRowBinding.claim -resources[].operations.read.access.authorityRowBinding.sourceColumn -resources[].operations.read.access.purpose -resources[].operations.read.access.purpose.allowed -resources[].operations.read.access.purpose.allowed[] -resources[].operations.read.access.purpose.claim -resources[].operations.read.access.scope -resources[].operations.read.disclosureProfile +resources[].operations.read.defaultRepresentation +resources[].operations.read.representations +resources[].operations.read.representations.public-register +resources[].operations.read.representations.public-register.access +resources[].operations.read.representations.public-register.disclosureProfile +resources[].operations.read.representations.registrar +resources[].operations.read.representations.registrar.access +resources[].operations.read.representations.registrar.access.authorityRowBinding +resources[].operations.read.representations.registrar.access.authorityRowBinding.claim +resources[].operations.read.representations.registrar.access.authorityRowBinding.sourceColumn +resources[].operations.read.representations.registrar.access.purpose +resources[].operations.read.representations.registrar.access.purpose.allowed +resources[].operations.read.representations.registrar.access.purpose.allowed[] +resources[].operations.read.representations.registrar.access.purpose.claim +resources[].operations.read.representations.registrar.access.scope +resources[].operations.read.representations.registrar.disclosureProfile resources[].processingDescriptions resources[].processingDescriptions[] resources[].processingDescriptions[].dpvProfileRef @@ -790,6 +887,12 @@ resources[].properties.*.label resources[].properties.*.semanticTerm resources[].properties.*.sourceColumn resources[].properties.*.sourceRequired +resources[].properties.*.transform +resources[].properties.*.transform.characters +resources[].properties.*.transform.kind +resources[].properties.*.transform.precision +resources[].properties.*.transform.reveal +resources[].properties.*.transform.sourceType resources[].properties.*.type resources[].recordContext resources[].recordContext.lifecycleState @@ -882,8 +985,8 @@ The three examples suggest a compact core model: registry contract -> source reference and reviewed view -> resource and published properties - -> compiled operations - -> maximum disclosure profile + -> compiled operation query shape + -> finite defaulted representations with access and disclosure -> optional requester property subset -> access constraints -> semantics, classification, and processing description @@ -897,6 +1000,8 @@ The examples also freeze these boundaries: - `fields` is one comma-separated property syntax across list, read, and lookup; - a live source remains useful for read and lookup but is unversioned, `no-store`, and has no paginated list; - handling uses `public`, `internal`, `confidential`, and `restricted`, while purpose and row binding remain explicit constraints; -- one maximum disclosure profile is compiled per operation, so different operations may differ but caller-dependent variants within one operation are deferred; +- one explicit default and a finite ordered representation set is compiled per operation; requester `fields` only narrows the selected profile and caller-derived variants are deferred; +- identification is schema-only and value-free; generated, imported, and manual classification review all bind the complete classification inventory before production compilation; +- only `partial-string` with Relay's fixed `***` marker and `date-precision` to `year` or `year-month` are transform forms; every transform produces a distinct reviewed property; - Mint and external issuers use one strict Relay JWT access-token profile; - generated capabilities and a maintained alignment note describe the written draft standards without consuming their legacy OpenAPI or claiming conformance. diff --git a/products/relay-v2/DEFINITION-OF-DONE.md b/products/relay-v2/DEFINITION-OF-DONE.md index 214862aa9..d01f221e3 100644 --- a/products/relay-v2/DEFINITION-OF-DONE.md +++ b/products/relay-v2/DEFINITION-OF-DONE.md @@ -1,7 +1,7 @@ # Relay V2 Definition of Done Status: Approved acceptance contract -Date: 2026-08-09 +Date: 2026-08-10 Product direction: [Relay V2 Product Concept](CONCEPT.md) Configuration design probes: [Relay V2 Configuration Examples](CONFIGURATION-EXAMPLES.md) @@ -22,9 +22,9 @@ No required behavior may remain as a stub, TODO, undocumented manual step, disab | Registry | Required shape | What it must prove | |---|---|---| -| Social assistance enrolment | Live SQLite, exact lookup only, protected properties, trusted purpose, authority-to-row binding, external authorization server | A sensitive person-related registry can answer a bounded consultation without enumeration, identifier read, selector disclosure, or domain-specific runtime behavior. | -| Business registration | Snapshot SQLite, anonymous public list and identifier read, predefined exact filters, pagination, public semantics | A genuinely public register can be discoverable and cacheable while remaining contract-bound rather than becoming a generic database API. | -| Civil event registration | Live SQLite, protected identifier read plus named exact lookup, different operation scopes and disclosure profiles, optional Mint issuer | A CRVS-shaped event register can support registrar and verification uses without exposing a collection, coupling Relay to Mint, or moving signed assertions into Relay. | +| Social assistance enrolment | Live SQLite, exact lookup only, limited and caseworker representations, partial-string transform, trusted purpose, authority-to-row binding, external authorization server | A sensitive person-related registry can answer a bounded consultation without enumeration, selector disclosure, or domain-specific runtime behavior. | +| Business registration | Snapshot SQLite, public default plus protected registrar representations, predefined exact filters, pagination, public semantics | A genuinely public register can isolate a protected representation while its reviewed pre-derived public view remains discoverable and cacheable. | +| Civil event registration | Live SQLite, registrar and supervisory representations over protected identifier read and named exact lookup, date-precision transform, no list | A CRVS-shaped event register can prove exact lookup and representation scope separation without exposing a collection, coupling Relay to Mint, or moving signed assertions into Relay. | Each definition must pass offline fixture evaluation and the real HTTP runtime as its own one-Registry deployment. All three must use the same compiler, @@ -37,7 +37,7 @@ prove in-process resource isolation without adding a fourth deployment project. | Area | Done when | |---|---| | Product boundary | One `relay` runtime and one separate `relayctl` implement the initial product. Relay is a read-only governed registry publisher, not a SQL proxy, write API, policy engine, credential issuer, or signed-assertion service. The existing `registryctl` is unchanged and unused by Relay V2. | -| Governed contract | A concise, closed, versioned authoring contract defines resources, source views, identifiers, properties, operations, disclosure profiles, semantics, classifications, access rules, bounds, and metadata visibility, with optional governance sidecars. Unknown fields are rejected. A deployment file may bind paths, listeners, one issuer, secrets, and audit storage but cannot override governed behavior. | +| Governed contract | A concise, closed, versioned authoring contract defines resources, source views, identifiers, properties, finite representations, operations, semantics, classifications, access rules, bounds, and metadata visibility. Each operation has one `defaultRepresentation`; each ordered `representations` entry owns exactly one `access` rule and `disclosureProfile`, while query shape remains operation-owned. Unknown fields are rejected. A deployment file may bind paths, listeners, one issuer, secrets, and audit storage but cannot override governed behavior. | | Registry identity | One contract and process describes exactly one Registry with a globally stable `registryIdentifier`, name, Registry Authority, optional operator, authoritative scope, base URI, and pinned alignment targets. Resources are Record types within that Registry. Authority, controller, publisher, and operator remain distinct roles even when one institution fills several. | | Registry Core | Every returned Record contains non-selectable `registryIdentifier`, `recordIdentifier`, `revisionIdentifier`, `lifecycleState`, `schemaReference`, `semanticModelReference`, `authorityIdentifier`, `recordedAt`, and selected `domainData`. Record identifier, revision, lifecycle, and recorded time are source-bound; recorded time is never Relay observation time. The Registry and Record identifier pair is stable, and the contract names the institution's identifier-lifecycle policy without claiming Relay can prove non-reassignment from one current database. | | Family capabilities | Each compiled operation carries a derived family and pattern: read is `consultation.retrieve`, list is `consultation.list`, and named exact lookup is constrained `consultation.search`. Capability discovery is generated from operations rather than separately authored. Relay makes no claim for Record Match or the Provisioning, Evidence, Write, Notification, Aggregate Data, Access Transparency, or Identity Federation families. | @@ -46,25 +46,26 @@ prove in-process resource isolation without adding a fourth deployment project. | SQLite source boundary | Snapshot and live read-only profiles use one hardened SQLite boundary with OS and engine read-only enforcement, defensive authorizer rules, bound values, consistent per-request transactions, schema fingerprints, and step, time, row, cell, response, queue, and concurrency limits. Writes, schema changes, control statements, attachment, extension loading, and unreviewed SQL are impossible through the public contract. | | Snapshot profile | A deployment-bound snapshot is captured outside the contract package, bound to its exact file identity and digest, refuses unsafe sidecars or path replacement, opens immutably, and reports a truthful source revision. Identical governed package and snapshot inputs produce identical revisions and generated artifacts. Snapshot mode is supported but not required for a deployment. | | Live profile | A live database is opened read-only while a separately trusted publisher may update it. Each response uses one consistent read transaction and verifies the expected schema fingerprint. Version one live sources compile read and exact lookup only, always return `sourceRevision: {profile: live, status: unversioned, value: null}`, use `no-store`, and emit no ETag. Publisher-owned revisions, live pagination, and live caching are deferred. | -| Closed operation model | Resources compile only declared list, identifier-read, and named exact-lookup operations. A list's access rule determines whether enumeration is public or protected; absence of list means no enumeration. Collection filters are direct publisher-defined camelCase query parameters, typed, non-personal, and exact-equality only. Any non-empty subset of declared filters is valid, and the contract separately permits or forbids unfiltered access. `pageSize`, `cursor`, and `fields` are reserved. Lookups have complete bounded body inputs and exactly zero or one disclosed result. Callers cannot add SQL, source columns, joins, expressions, operators, paths, projection expressions, sort orders, or page traversal. | -| Disclosure and requester minimization | Every operation selects one reviewed disclosure profile whose `properties` list is both maximum and default. A caller may request a non-empty, duplicate-free comma-separated subset of selectable `domainData` property keys and nothing else. Registry Core fields remain present. Unknown, internal, source-column, or malformed selections fail before source access. Field selection cannot change predicates, bindings, derivations, validation, authorization, effective handling, audit, quota, metadata, or cache posture. Caller-dependent maximum entitlement variants are explicitly deferred. | -| HTTP and pagination | Business routes are under `/v2`; `/health`, `/ready`, and `/openapi.json` are unversioned. `GET /v2` publishes safe first-class Registry service metadata and visible derived capabilities. Lists accept bounded `pageSize` and opaque `cursor` and return `{items, pageInfo: {nextCursor}, meta}` with nullable `nextCursor`. Cursor integrity binds revisions, operation, filters, fixed order, field set, authorization context, and expiry; each page is reauthorized. Single reads and resolved lookups return `{data, meta}`. No caller sorting exists. | -| Query and serialization minimization | Relay may read the complete fixed reviewed projection so it can validate the authoritative Record before disclosure. Unrequested and hidden columns are never serialized. Ordinary JSON and JSON-LD disclose the same Registry Core and selected domain data with deterministic property order. JSON-LD adds the generated context and a derived `@id` without replacing `recordIdentifier`. Cacheable public snapshot responses use a strong exact-byte ETag, `Vary: Accept, Authorization`, `If-None-Match`, and `304`; non-public and live responses are `no-store` and have no ETag. | +| Closed operation model | Resources compile only declared list, identifier-read, and named exact-lookup operations. A list's operation-owned query shape determines whether enumeration is permitted; absence of list means no enumeration. Collection filters are direct publisher-defined camelCase query parameters, typed, non-personal, and exact-equality only. Any non-empty subset of declared filters is valid, and the contract separately permits or forbids unfiltered access. `pageSize`, `cursor`, `fields`, and `representation` are reserved. Lookups have complete bounded body inputs and exactly zero or one disclosed result. Callers cannot add SQL, source columns, joins, expressions, operators, paths, projection expressions, sort orders, or page traversal. | +| Representation selection and requester minimization | Every operation has a finite ordered `representations` map and exactly one explicit `defaultRepresentation`. If any representation is public, the default must be public so omission is truthful in public OpenAPI and anonymous clients never select a hidden protected default. The direct `representation` parameter accepts exactly one non-empty compiled identifier; absence selects the default. Relay authenticates a supplied bearer before public selection and authorizes only the selected representation. Malformed, repeated, or empty selection is `400 request.representation_invalid`; unknown or unavailable selection is `404 representation.not_found`; denial, including purpose or row binding, is `403 consultation.denied`; none falls back to another representation or reaches source access. `fields` may select only a non-empty duplicate-free subset of that selected profile. Registry Core remains present. Unknown, internal, source-column, cross-profile, or malformed selections fail before source access and cannot change predicates, bindings, transforms, validation, authorization, effective handling, audit, quota, metadata, or cache posture. | +| HTTP and pagination | Business routes are under `/v2`; `/health`, `/ready`, and `/openapi.json` are unversioned. `GET /v2` publishes safe first-class Registry service metadata and visible derived capabilities. Lists accept bounded `pageSize` and opaque `cursor` and return `{items, pageInfo: {nextCursor}, meta}` with nullable `nextCursor`. Cursor integrity binds revisions, operation, selected representation and disclosure profile, transform inventory, filters, fixed order, field set, authorization context, and expiry; each page is reauthorized. Single reads and resolved lookups return `{data, meta}`. No caller sorting exists. | +| Query, transformation, and serialization | Relay may read the complete fixed reviewed processing projection so it can validate the authoritative Record before disclosure. Unrequested and hidden columns are never serialized. Only compiled `partial-string` and `date-precision` transforms run: `partial-string` uses the fixed Relay marker `***` and bounded Unicode-scalar prefix or suffix reveal, while `date-precision` produces only `year` or `year-month` from canonical date/date-time input. A transformed property has its own name, term, datatype, and classification. A partial-string input no longer than its reveal bound succeeds as `***` without revealing a source character. Required null, wrong type, noncanonical value, or transform input/output length failure releases nothing: read and list return `503 source.unavailable`, while exact lookup conceals an unsafe selected row as `404 consultation.unresolved`. Ordinary JSON and JSON-LD disclose the same Registry Core identity and domain values with deterministic property order. JSON-LD adds the generated context and a derived `@id` without replacing `recordIdentifier`. Cacheable responses require public selected representation, public processing handling, and a snapshot; their strong ETag binds exact selected-profile bytes, `Vary: Accept, Authorization`, `If-None-Match`, and `304`. Other responses are `no-store` and have no ETag. | | Semantic contract | Every resource and property has a stable local semantic identity, datatype, cardinality, label, and description. `relayctl` can generate reviewed starter semantics, JSON-LD context, permitted-representation JSON Schema and SHACL, full-record validation schema and SHACL, and codelist scaffolding without requiring prior semantic-web expertise. The representation artifacts require Registry Core and validate selectable domain properties only when present; full-record artifacts retain source requiredness. `semanticModelReference` resolves to the generated vocabulary/model and the context is linked separately. Generated suggestions are visibly non-authoritative until accepted. | | External semantic alignment | Optional mappings to SEMIC, PublicSchema, schema.org, or another profile are curated, relation-qualified, versioned, and digest-pinned. Relay fetches no vocabulary and performs no inference at request time. Mapping changes appear in change-impact reports and cannot silently widen disclosure. | -| Classification and processing | Every published property and every reviewed source-view column has an effective reviewed privacy, institutional, and technical-handling classification with provenance and version. Resource defaults reduce repetition; compilation expands defaults and explicit overrides before validation. Simple property columns inherit unless the source is stricter; hidden Registry Core, selector, row-binding, revision, filter, and order columns are accounted for explicitly. Handling is one of ordered `public`, `internal`, `confidential`, or `restricted`; non-public data requires authentication, operation scope, `no-store`, and durable value-free audit, and restricted data cannot be listed. Purpose and row binding remain explicit access constraints. More restrictive or uncertain classification fails closed. Processing descriptions and DPV projections are optional governance sidecars and never runtime policy. | +| Identification, classification, and review | The existing axes remain exact: `semanticTerm`, `privacy`, `institutional`, `handling`, `status`, and `provenanceRef`. `relayctl` identification is offline, schema-only, deterministic, explainable, and value-free; it reads no source values and no candidate self-approves. Every property and every processed source-view column has an effective reviewed classification. `sourceColumnClassifications` is explicit and complete for every transformed or multiply-bound column. The reviewed `ClassificationReview` at `classifications.provenanceRef` digest-binds Registry identity and the classification inventory; generated review additionally binds its accepted report and exact rule pack. Missing, suggested, uncertain, stale, or tampered review fails production compilation. Manual and imported review remain first-class. | +| Processing versus disclosure handling | Resource defaults reduce repetition; compilation expands defaults and explicit overrides before validation. Processing handling is the maximum across Registry Core, direct output, transform input, selector, filter, order, and row-binding source columns. Disclosure handling is the maximum across serializable properties for the selected representation. Authentication, audit, cache, source controls, and public eligibility use processing handling. A transform may disclose a lower reviewed output but cannot weaken raw-source processing controls. A public representation may not process a non-public raw column: public masked publication requires a reviewed pre-derived public view column. Handling is one of ordered `public`, `internal`, `confidential`, or `restricted`; non-public processing requires authentication, scope, `no-store`, and durable value-free audit, and restricted data cannot be listed. Purpose and row binding remain explicit access constraints. | | Authentication and issuers | Relay acts as an OAuth 2.0 JWT resource server for protected operations and Version one configures exactly one issuer per Registry deployment. Relay strictly verifies issuer, audience, token type, algorithm, key, time, client or subject, token identifier, and scope claims. Missing or invalid credentials return safe `401` responses; insufficient scope returns `403`. Anonymous access exists only on operations explicitly compiled as public. | | Operation authorization | List, read, and named lookup use distinct registered scopes that are unique across the Registry contract. Trusted purpose and authority-to-row binding are optional compiled constraints and can come only from the resolved principal or a direct verified scalar claim. Caller filters or headers never create authority. A lookup-only client cannot enumerate or perform identifier reads, even when another client can use those operations on the same deployment. | | Optional Mint pairing | Relay accepts a conforming token from an external authorization server without Mint. A Mint deployment may be paired when it emits the same Relay audience, operation scopes, and optional authority claims from server-side grants. Relay has no Mint runtime dependency or Mint-specific authentication branch. Mint changes and a Mint integration journey do not block the core Relay V1 acceptance path. | | Lookup containment | Sensitive selectors use a bounded request body, are bound rather than rendered, and never appear in URLs, errors, logs, metrics, traces, audit, or responses. No match, ambiguity, policy-hidden record, unknown or protected identifier, and unsafe source record share one `404` outcome with the same Registry Stack problem type, code, detail, schema, and headers. Only independently generated trace correlation may differ. Invalid syntax is a value-free bounded request error. Rate and concurrency limits make consultation abuse observable and bounded. | | Validation and failure | Every selected row is schema-validated before release. Relay never skips, coerces, truncates, or partially releases an invalid row to preserve success. Errors use the fixed Registry Stack status/code catalog and derived type URIs plus `traceId`, exactly the 32-lowercase-hex trace ID of the effective valid or server-generated W3C Trace Context. Caller-supplied `tracestate` is never propagated. Problems contain no field-error array, SQL, paths, schema internals, selector values, source values, token material, or subject identifiers. Draft GovStack error namespaces are not used. Required audit or other release-gate failure prevents disclosure. | | Unsigned response boundary | Relay responses are not signed. TLS and access-token verification protect the live exchange, while revisions, ETags, provenance, and tamper-evident audit support accountability without being described as signatures. Evidence can consume a fixed Relay lookup when a portable signed minimum-disclosure assertion is required. | -| Audit and provenance | Every public or protected data request processed by Relay durably records either a refusal before returning or a pre-source attempt followed by one terminal release or unresolved outcome. Durable audit gates source access and response release. Events carry stable identifiers for Registry, resource, operation, access-rule revision, optional purpose, row-boundary kind, disclosure profile, selected-property set or digest, handling level, contract revision, and truthful source revision. Anonymous calls record an anonymous principal kind. Audit contains no tokens, selector values, source values, response values, SQL, or raw subject identifiers. The safeguards report names public shared-cache hits as outside Relay observation. | -| Metadata visibility | Registry service identity is public. Other resource, capability, OpenAPI, semantic, classification, processing, and operational metadata is `public`, `operation-bound` behind the same static gate as the operation whose Record links it, or `operator-only` in package/CLI with no HTTP route. Protected resource existence and selector shape are indistinguishable from unknown, and discovery performs no source query. The package contains full OpenAPI; `/openapi.json` is a deterministic safe public projection. Compilation fails if any successful Record audience cannot resolve a safe operation-bound projection of its exact `schemaReference` and `semanticModelReference`. | +| Audit and provenance | Every public or protected data request processed by Relay durably records either a refusal before returning or a pre-source attempt followed by one terminal release or unresolved outcome. Durable audit gates source access and response release. Events carry stable identifiers for Registry, resource, operation, access-rule revision, optional purpose, row-boundary kind, representation, disclosure profile, selected-property set or digest, processing handling, disclosure handling, transform identifiers, contract revision, and truthful source revision. Terminal audit covers exact held selected-profile bytes before release. Anonymous calls record an anonymous principal kind. Audit contains no tokens, selector values, source values, transformed values, response values, SQL, or raw subject identifiers. The safeguards report names public shared-cache hits as outside Relay observation. | +| Metadata visibility and per-profile artifacts | Registry service identity is public. Other resource, capability, OpenAPI, semantic, classification, processing, and operational metadata is `public`, `operation-bound` behind the same static gate as the operation and representation whose Record links it, or `operator-only` in package/CLI with no HTTP route. Public metadata never inventories a protected representation identifier, profile schema, SHACL shape, JSON-LD context, semantic model, classification, processing description, or OpenAPI path. The package contains every profile's artifacts and full OpenAPI; `/openapi.json` is a deterministic safe public projection. Compilation fails if any successful Record audience cannot resolve a safe operation-bound projection of its exact profile schema and semantic model. | | Freshness and caching | Snapshot and live responses expose truthful, profile-specific revision and cache behavior. Every public snapshot response uses `Cache-Control: public, no-cache`, a strong exact-byte ETag, and revalidation. Every non-public or live response is `no-store`. `Vary: Authorization` prevents an anonymous cached `200` from serving a request with an invalid bearer. No response implies a stable cross-request snapshot, and field subsets cannot collide. | | Generated contracts | OpenAPI 3.1, JSON Schema, SHACL, JSON-LD contexts, codelists, and capability discovery are generated reproducibly from the compiled contract. Full and public OpenAPI projections have drift checks. No artifact is generated from the obsolete Digital Registries OpenAPI. | | Standards alignment | A concise maintained note pins the reviewed Digital Registries and API Design Guide drafts, maps adopted concepts and Consultation patterns, and records intentional gaps and rejected rules. It uses alignment language only, never conformance or certification. Machine-readable alignment reports, GovStack linting, Registry Manifest projection, and DPV generation are later optional tooling. | -| `relayctl` adopter journey | An adopter can initialize a project, inspect a SQLite schema without values by default, generate starter semantics and classifications, validate, generate artifacts, run fixtures, inspect a semantic and disclosure diff, and package a deployment without editing Rust. `relayctl` uses the same Relay compiler and fixture library as `relay` and implements no second product semantics. | +| `relayctl` adopter journey | An adopter can initialize a project, inspect a SQLite schema without values by default, generate starter semantics, deterministic identification, classification inventory, processed-versus-disclosed representation report, contextual findings, and a review sidecar starter; validate, generate artifacts, run fixtures, inspect a semantic/classification/representation diff, and package a deployment without editing Rust. Generated output defaults below `generated/{reports,governance}`. A generated-review project copies its accepted report to `reports/identification-report.json` and binds it from `governance/classification-review.yaml`; imported and manual projects need no generated report. `relayctl` uses the same Relay compiler and fixture library as `relay` and implements no second product semantics. | | Change impact and safeguards | A contract diff identifies new properties, wider operations or filters, relaxed classification, changed disclosure profiles, removed row bindings, expanded scopes or purposes, changed metadata visibility, source-view changes, and semantic mapping changes. Each applicable DPI safeguard is linked to a concrete mechanism, enforcement point, negative test, evidence artifact, and named institutional responsibility. No certification claim is generated. | | Operability | Unauthenticated `/health` reports only liveness and `/ready` reports only ability to serve the compiled Registry, each as minimal `application/json` on `200`, `no-store`, and safe Registry Stack Problem `503` on failure. Neither exposes Registry or source details. Startup, shutdown, bounded concurrency, audit durability, issuer-key refresh, source unavailability, schema drift, and live publisher replacement have documented and tested behavior. Relay emits structured value-free lifecycle logs and bounded request outcomes using only a fixed method class, route template, status, latency, and trace identifier. Version one has no `/metrics` route or in-process metrics registry; operators derive aggregate metrics outside Relay from these logs and durable audit without protected values or high-cardinality subject labels. | | Verification evidence | Focused positive, negative, boundary, and non-disclosure tests pass for every security-sensitive behavior. Formatting, package check, Clippy with warnings denied, package tests, workspace tests, dependency policy, contract drift, exposure inventory, source neutrality, config-key-path, and reproducible-generation gates pass on one revision. CI path selection is itself tested for every new owning path. | @@ -90,16 +91,17 @@ For each of the three coequal registries: 3. offline fixtures and the real HTTP service return the same semantic result; 4. every returned Record has valid Registry Core context, and `recordedAt` and `revisionIdentifier` come from the source view; 5. ordinary JSON and JSON-LD are data-equivalent and validate against generated contracts; -6. default disclosure and at least two valid `domainData` subsets succeed while Registry Core remains complete; -7. an unknown property, source-column name, duplicate property, and malformed selection fail without source or value leakage; -8. invalid selected source rows fail the whole response closed; every Registry proves at least one such refusal, and the coequal suite covers wrong type, missing required value, extra unexpected value, and excessive size; +6. default and explicitly requested representations, plus at least two valid `domainData` subsets within a selected representation, succeed while Registry Core remains complete; +7. an unknown property, source-column name, cross-profile property, duplicate property, malformed selection, malformed/repeated representation, unknown representation, and denied selected representation fail without source or value leakage or fallback; +8. invalid selected source rows and required transform null/type/length boundaries fail the whole response closed; every Registry proves at least one such refusal, and the coequal suite covers wrong type, missing required value, extra unexpected value, excessive size, partial-string, and date-precision boundaries; 9. restarting with identical inputs reproduces the same compiled contract and generated artifacts; 10. a schema or governed-contract change is detected and cannot silently widen the active API; -11. full packaged OpenAPI, safe public OpenAPI, semantic, schema, SHACL, codelist, and capability artifacts reproduce byte for byte. +11. full packaged OpenAPI, safe public OpenAPI, per-profile semantic, schema, SHACL, JSON-LD, classification, processing, codelist, and capability artifacts reproduce byte for byte. +12. schema-only identification reports, classification inventories, processed-versus-disclosed representation reports, contextual findings, and review-sidecar staleness/tamper refusals are deterministic and value-free. Shared security acceptance additionally proves that: -1. a response and its emitted value-free audit correlate through trace and request-operation identifiers and agree on Registry, resource, compiled operation, contract, disclosure profile, selected properties, row-boundary kind, and truthful source revision; +1. a response and its emitted value-free audit correlate through trace and request-operation identifiers and agree on Registry, resource, compiled operation, contract, selected representation and disclosure profile, selected properties, processing/disclosure handling, transform identifiers, row-boundary kind, and truthful source revision; 2. audit never records response bytes, response digests, Record identifiers, raw subject identifiers, or fixture canaries; 3. Problems do not contain fixture canaries, trace headers carry only Relay-validated fixed identifiers, and operational log dimensions cannot contain request paths, identifiers, query values, headers, bodies, selectors, or principals; 4. adopter reports and generated or packaged artifacts pass fixture-canary scans. @@ -114,6 +116,8 @@ value-free operational log dimensions. - exact match, no match, ambiguity, policy-hidden row, and invalid selected row; - correct purpose and service-area row binding, missing purpose, wrong purpose, missing binding, and wrong binding; - lookup scope succeeds while list and identifier read are absent regardless of token scope; +- limited is the default and uses `partial-string`; entitled caseworker selection returns its own profile, purpose, and row binding, while a wrong scope, purpose, or binding does not fall back; +- the social journey proves ordinary `partial-string` output; focused transform and real-router tests prove null, wrong-type, and overlong refusal plus the fixed `***` result for a short input, without exposing raw input in JSON, JSON-LD, audit, report, or problem output; - selectors and internal person, household, and service-area binding columns remain absent from every response and diagnostic surface; - live update within the compatible schema appears under a truthful later Record revision without mixing rows inside one response; - the deployment advertises constrained `consultation.search` only and makes no Base Registry or Record Match claim. @@ -125,20 +129,35 @@ value-free operational log dimensions. - no filter when allowed, each declared direct camelCase exact filter, a subset of declared filters, unknown filter, unsupported operator, and attempted arbitrary sort; - deterministic ordering and pagination with no duplicate or missing record across the unchanged snapshot; - public field subset, JSON-LD context, SEMIC mapping artifact, SHACL, and codelist validation; +- public default list/read can request only public representations; protected registrar representation metadata, schema, SHACL, JSON-LD, processing, and OpenAPI are absent from public discovery; +- a public representation reads only the reviewed pre-derived public view, never a non-public raw column, and a profile-bound cursor or ETag cannot cross into another representation; - snapshot digest, path replacement, unsafe sidecar, write attempt, and schema mismatch failures. - `consultation.list` and `consultation.retrieve` discovery with no unsupported family claim. ### Civil-event registry cases - protected identifier read and named exact verification lookup, with collection listing absent; -- registrar read scope cannot be inferred from verification lookup scope, and vice versa; -- the registrar and verification operations receive their different compiled disclosure profiles, each safely narrowable by the requester; +- registrar and supervisory representations are selected explicitly, are independently scoped, and never fall back; neither read nor lookup scope can synthesize the other; +- the civil journey proves `date-precision` (`year` and `year-month`) with distinct output terms/types; focused transform and real-router tests reject null, noncanonical, incompatible, and oversized source values with value-free source failure; - the external-issuer path is complete; a later optional Mint pairing must traverse the same verifier and access-decision path; - no match, ambiguity, jurisdiction-hidden row, invalid event record, wrong purpose, and wrong jurisdiction binding collapse according to the lookup contract; - the fixed Relay lookup remains an ordinary protected HTTP source contract suitable for a future Evidence integration, without adding signing behavior to Relay; a real Evidence pairing is a separate non-blocking journey. - the same Registry Core fields remain present under both operation-specific disclosure profiles. +- no list route exists, including when a caller asks for a representation identifier. + +### Classification-review methods + +- Social assistance uses `generated` review: its accepted + `reports/identification-report.json` and exact core-pack digest bind the + classification-review sidecar. +- Business registration uses `imported` review and remains valid without an + identification report. +- Civil event uses `manual` review and remains valid without an identification + report. +- Missing, stale, deterministic-report-mismatched, sidecar-tampered, or + generated-pack-mismatched review is refused before production compilation. ### Cross-product and neutrality cases diff --git a/products/relay-v2/IMPLEMENTATION.md b/products/relay-v2/IMPLEMENTATION.md index 984b4ffd4..461df7024 100644 --- a/products/relay-v2/IMPLEMENTATION.md +++ b/products/relay-v2/IMPLEMENTATION.md @@ -1,7 +1,7 @@ # Relay V2 Implementation Plan Status: Approved implementation plan -Date: 2026-08-09 +Date: 2026-08-10 Product direction: [Relay V2 Product Concept](CONCEPT.md) Acceptance contract: [Relay V2 Definition of Done](DEFINITION-OF-DONE.md) Configuration probes: [Relay V2 Configuration Examples](CONFIGURATION-EXAMPLES.md) @@ -69,13 +69,15 @@ owns: - resources, Registry Core source bindings, URL-safe camelCase property keys, published properties, datatypes, source requiredness, codelists, labels, descriptions, and local semantic IRIs; -- compiled list, read, or named exact-lookup operations; list presence and its - access rule derive the enumeration posture; +- compiled list, read, or named exact-lookup operations; query shape remains + operation-owned while each operation declares one `defaultRepresentation` + and finite ordered `representations` with representation-owned `access` and + `disclosureProfile`; list presence derives the enumeration posture; - direct typed equality filters, explicit unfiltered permission, fixed ordering, page bounds, lookup selectors, and query limits; -- one disclosure profile per operation whose `properties` list is both maximum - and default; all Version one operations permit callers to narrow `domainData` - with `fields`; +- reusable disclosure profiles whose `properties` lists are selected only by a + compiled representation; callers may narrow only the selected profile with + `fields`, never cross profiles; - privacy, institutional, and `public`/`internal`/`confidential`/`restricted` technical handling classifications for every published property and reviewed source-view column, including status, provenance, and version, with reviewed @@ -157,6 +159,42 @@ Registry Manifest projection is deferred portability tooling. Source columns, access rules, scopes, disclosure, classifications, processing constraints, and limits remain Relay-owned and never enter Manifest. +### Identification, review, and governed representation increment + +The compiler owns the closed representation model and never receives +caller-authored transforms or policy expressions. It validates exactly one +`defaultRepresentation` against each finite operation map; compiles access, +disclosure, processing handling, disclosure handling, transform inventory, and +artifact identity per representation; and carries operation query shape, +filters, selectors, order, pagination, and quotas outside that map. + +`sourceColumnClassifications` remains resource-owned. The compiler requires an +explicit complete reviewed classification for a transformed or multiply-bound +column, accounts for all Registry Core and hidden processing columns, and +rejects a public representation that processes a non-public column. It accepts +only `partial-string` and `date-precision`: their pure implementation is +separate from parsing, SQL, authorization, and serialization. The marker is +the fixed Relay constant `***`; output types are `string`, `year`, or +`year-month` as applicable. + +Identification is a `relayctl` offline workflow over observed schema and the +embedded digest-pinned core pack, never source values. `generate` emits the +four fixed report paths under `generated/reports/` and the starter under +`generated/governance/`. A `ClassificationReview` sidecar is enforced only by +the governed compilation path. Generated review binds an accepted copied report +and rule pack; imported and manual review bind only the inventory and review +authority. The compiler's artifact and package paths carry every profile for +operator review while public projection includes only public-visible profile +artifacts. + +The HTTP layer parses `representation` once before source access, authenticates +any supplied bearer before public default selection, authorizes the exact +profile, then narrows `fields` within it. Cursor and public ETag construction +include profile identity and transform inventory. Protected, non-public +processing, and live responses are `no-store`. Audit adds representation, +disclosure, selected properties, both handling levels, and transform IDs, but +no values; terminal audit gates the exact serialized bytes. + ### Shared compiler and tooling boundary `registry-relay-v2` exposes one library API for parsing, compilation, schema @@ -353,8 +391,10 @@ no `pageSize`, `fields`, or filters; the cursor restores the immutable query context. Repeating or changing first-page parameters with a cursor is `query.cursor_invalid`. -Version 1 quota state is bounded and in-process, so the declared deployment -profile is one Relay replica per Registry. A multi-replica deployment must put +Version 1 accepts one deployment quota with `requestsPerMinute` and `burst`. +Relay maintains one bucket per compiled operation, shared by all of that +operation's representations. The state is bounded and in-process, so the +declared deployment profile is one Relay replica per Registry. A multi-replica deployment must put a trusted ingress or shared limiter in front that enforces the same ceilings; distributed rate-limit state is outside the initial runtime. @@ -425,6 +465,7 @@ error array is emitted. |---|---:|---|---| | Malformed JSON, query syntax, or lookup body | 400 | `consultation.invalid_request` | `the consultation request is invalid` | | Invalid, empty, repeated, unknown, or non-public `fields` selection | 400 | `request.fields_invalid` | `field selection is invalid` | +| Malformed, empty, or repeated `representation` selection | 400 | `request.representation_invalid` | `representation selection is invalid` | | Undeclared filter | 400 | `filter.unknown_field` | `filter is not declared for this operation` | | Invalid filter value or combination | 400 | `filter.invalid_value` | `filter value is invalid` | | Malformed, expired, stale, or differently bound cursor | 400 | `query.cursor_invalid` | `cursor is invalid for this query` | @@ -432,6 +473,7 @@ error array is emitted. | Invalid credential | 401 | `auth.invalid_credential` | `bearer access token validation failed` | | Insufficient scope, purpose, or row authority | 403 | `consultation.denied` | `the consultation is not permitted` | | Unknown or visibility-hidden resource or artifact | 404 | `resource.not_found` | `the requested resource was not found` | +| Unknown or unavailable requested representation | 404 | `representation.not_found` | `the requested representation was not found` | | Unknown, hidden, ambiguous, or unsafe Record outcome | 404 | `consultation.unresolved` | `the requested record was not resolved` | | Unsupported response `Accept` | 406 | `representation.unsupported` | `the requested representation is not supported` | | Request body too large | 413 | `internal.payload_too_large` | `request body exceeds the configured limit` | diff --git a/products/relay-v2/STANDARDS-ALIGNMENT.md b/products/relay-v2/STANDARDS-ALIGNMENT.md index 05e472f99..1d54233a4 100644 --- a/products/relay-v2/STANDARDS-ALIGNMENT.md +++ b/products/relay-v2/STANDARDS-ALIGNMENT.md @@ -18,6 +18,8 @@ claim. The obsolete Digital Registries OpenAPI is not an input. | Consultation List | Deterministic list is compiled only when the resource declares `list`; pagination and filters are closed. | | Consultation Search | A named exact lookup is the only accepted search-shaped operation. It returns one governed Record or the unresolved outcome. | | Registry semantics | Every resource and property has a stable local semantic identity; JSON-LD, JSON Schema, and SHACL artifacts are compiler outputs. | +| Governed representations | A compiled operation may expose only its finite reviewed representations, each with its own access, disclosure, semantic, schema, SHACL, JSON-LD, classification, and processing artifact. This is controlled publication, not content negotiation or dynamic ABAC. | +| Data governance review | Schema-only identification supplies deterministic review evidence. Classification remains Registry Authority review metadata and constrains compilation; it never grants an entitlement or becomes a remote runtime policy. | | Capability discovery | The public and protected inventories are derived from compiled operations and their visibility. | | API description | One full OpenAPI 3.1 document is package-only and one deterministic public subset is exposed at `/openapi.json`. | | API error discipline | Relay uses RFC 9457 problems, stable Registry Stack codes, W3C Trace Context correlation, and value-free details. | @@ -30,6 +32,9 @@ claim. The obsolete Digital Registries OpenAPI is not an input. retain their language metadata. - Registry Manifest, machine-readable GovStack alignment, DPV projection, and a GovStack linter remain future adopter-tooling projections. +- Dynamic masking, generic tags, external PDP, value sampling, and a general + data-catalog integration remain outside this alignment. Only reviewed + `partial-string` and `date-precision` output properties are in scope. - A current SQLite source and its lifecycle policy do not prove that an institution has never reassigned an identifier. - Relay responses are unsigned. Portable signed minimum disclosure belongs to diff --git a/products/relay-v2/acceptance/business-registry/expected-http.yaml b/products/relay-v2/acceptance/business-registry/expected-http.yaml index 6aec7958a..dfbf1874a 100644 --- a/products/relay-v2/acceptance/business-registry/expected-http.yaml +++ b/products/relay-v2/acceptance/business-registry/expected-http.yaml @@ -1,6 +1,14 @@ schemaVersion: relay.registrystack.org/http-journey/v1alpha1 registry: urn:example:registry:registered-businesses -authorizations: {} +authorizations: + business-registrar: + principal: synthetic-business-registrar + scopes: [registry:business:read-registrar] + claims: {} + business-unentitled: + principal: synthetic-business-reader + scopes: [registry:business:reader] + claims: {} steps: - id: registry-discovery request: {method: GET, path: /v2} @@ -63,6 +71,30 @@ steps: status: 200 recordIdentifier: BIZ-SYNTH-0001 cache: public-snapshot-revalidation + - id: registrar-read + authorizationFixture: business-registrar + request: + method: GET + path: /v2/resources/registered-business/records/BIZ-SYNTH-0001 + query: {representation: registrar, fields: "registrarLegalName,registrarNote"} + expect: + status: 200 + registryCoreRequired: true + domainDataKeys: [registrarLegalName, registrarNote] + cache: no-store + - id: registrar-representation-denied + authorizationFixture: business-unentitled + request: + method: GET + path: /v2/resources/registered-business/records/BIZ-SYNTH-0001 + query: {representation: registrar} + expect: {status: 403, code: consultation.denied} + - id: public-representation-unknown + request: + method: GET + path: /v2/resources/registered-business/records/BIZ-SYNTH-0001 + query: {representation: registrar-private} + expect: {status: 404, code: representation.not_found} - id: identifier-read-jsonld request: method: GET @@ -125,4 +157,4 @@ steps: expect: {status: 400, code: filter.unknown_field} - id: invalid-source-row request: {method: GET, path: /v2/resources/registered-business/records/BIZ-SYNTH-BAD1} - expect: {status: 404, code: consultation.unresolved, equivalenceClass: unresolved} + expect: {status: 503, code: source.unavailable} diff --git a/products/relay-v2/acceptance/business-registry/fixture.sql b/products/relay-v2/acceptance/business-registry/fixture.sql index b49c08428..b17a31d0e 100644 --- a/products/relay-v2/acceptance/business-registry/fixture.sql +++ b/products/relay-v2/acceptance/business-registry/fixture.sql @@ -6,24 +6,28 @@ CREATE TABLE source_registered_businesses ( lifecycle_state TEXT NOT NULL, recorded_at TEXT NOT NULL, legal_name TEXT NOT NULL, + public_legal_name TEXT NOT NULL, + registrar_note TEXT NOT NULL, registration_status TEXT NOT NULL, legal_form TEXT NOT NULL, jurisdiction_code TEXT NOT NULL ) STRICT; INSERT INTO source_registered_businesses VALUES -('BIZ-SYNTH-0001', '7', 'ACTIVE', '2026-06-01T08:00:00Z', 'Example Orchard Cooperative', 'ACTIVE', 'COOPERATIVE', 'EX-A'), -('BIZ-SYNTH-0002', '4', 'ACTIVE', '2026-06-02T08:00:00Z', 'Synthetic River Trading Ltd', 'ACTIVE', 'LIMITED_COMPANY', 'EX-B'), -('BIZ-SYNTH-0003', '9', 'SUSPENDED', '2026-06-03T08:00:00Z', 'Demonstration Workshop Association', 'SUSPENDED', 'ASSOCIATION', 'EX-A'), -('BIZ-SYNTH-0004', '2', 'RETIRED', '2026-06-04T08:00:00Z', 'Fixture Market Cooperative', 'CLOSED', 'COOPERATIVE', 'EX-B'), -('BIZ-SYNTH-BAD1', '1', 'ACTIVE', 'not-a-date-time', 'Invalid Fixture Enterprise', 'ACTIVE', 'LIMITED_COMPANY', 'EX-B'); +('BIZ-SYNTH-0001', '7', 'ACTIVE', '2026-06-01T08:00:00Z', 'Example Orchard Cooperative', 'Example Orchard Cooperative', 'Registrar note A', 'ACTIVE', 'COOPERATIVE', 'EX-A'), +('BIZ-SYNTH-0002', '4', 'ACTIVE', '2026-06-02T08:00:00Z', 'Synthetic River Trading Ltd', 'Synthetic River Trading Ltd', 'Registrar note B', 'ACTIVE', 'LIMITED_COMPANY', 'EX-B'), +('BIZ-SYNTH-0003', '9', 'SUSPENDED', '2026-06-03T08:00:00Z', 'Demonstration Workshop Association', 'Demonstration Workshop Association', 'Registrar note C', 'SUSPENDED', 'ASSOCIATION', 'EX-A'), +('BIZ-SYNTH-0004', '2', 'RETIRED', '2026-06-04T08:00:00Z', 'Fixture Market Cooperative', 'Fixture Market Cooperative', 'Registrar note D', 'CLOSED', 'COOPERATIVE', 'EX-B'), +('BIZ-SYNTH-BAD1', '1', 'ACTIVE', 'not-a-date-time', 'Invalid Fixture Enterprise', 'Invalid Fixture Enterprise', 'Registrar note invalid', 'ACTIVE', 'LIMITED_COMPANY', 'EX-B'); CREATE VIEW relay_registered_businesses AS SELECT registration_number, record_revision, lifecycle_state, recorded_at, - legal_name, + public_legal_name, + legal_name AS registrar_legal_name, + registrar_note, registration_status, legal_form, jurisdiction_code diff --git a/products/relay-v2/acceptance/business-registry/governance/classification-review-rationale.md b/products/relay-v2/acceptance/business-registry/governance/classification-review-rationale.md new file mode 100644 index 000000000..39e9798dd --- /dev/null +++ b/products/relay-v2/acceptance/business-registry/governance/classification-review-rationale.md @@ -0,0 +1,5 @@ +# Classification review rationale + +The public representation uses the reviewed pre-derived public view column. +Registrar-only properties retain confidential handling and cannot appear in +public metadata or cacheable responses. diff --git a/products/relay-v2/acceptance/business-registry/governance/classification-review.yaml b/products/relay-v2/acceptance/business-registry/governance/classification-review.yaml new file mode 100644 index 000000000..26758edd8 --- /dev/null +++ b/products/relay-v2/acceptance/business-registry/governance/classification-review.yaml @@ -0,0 +1,9 @@ +apiVersion: relay.registrystack.org/classification-review/v1 +kind: ClassificationReview +registryIdentifier: urn:example:registry:registered-businesses +classificationInventoryDigest: sha256:730dc3a3fed72d17efb443fc482533047ad716129126c78a78dcdd3500010c6d +method: imported +reviewer: urn:example:institution:company-registrar +reviewDate: 2026-08-10 +status: reviewed +rationaleRef: governance/classification-review-rationale.md diff --git a/products/relay-v2/acceptance/business-registry/registry.yaml b/products/relay-v2/acceptance/business-registry/registry.yaml index 141ad0262..4f0654e45 100644 --- a/products/relay-v2/acceptance/business-registry/registry.yaml +++ b/products/relay-v2/acceptance/business-registry/registry.yaml @@ -39,12 +39,12 @@ classifications: privacy: {scheme: https://w3id.org/dpv, version: "2.3"} institutional: {scheme: https://business.example.invalid/classification, version: "1"} handling: {scheme: https://id.registrystack.org/vocab/handling, version: "1"} - provenanceRef: governance/legal-basis.yaml + provenanceRef: governance/classification-review.yaml sources: companies: kind: sqlite profile: snapshot - expectedSchemaFingerprint: sha256:c978e36d7f0de71e8aa8245cdc8501ffdd760d87d9e6e0df97fd1228712361f1 + expectedSchemaFingerprint: sha256:5f12cd971dfa9cafd98018bd8723f2c3902e7d96b3c57ecf5c20e4885eb806a8 resources: - id: registered-business title: Registered business @@ -59,6 +59,10 @@ resources: revisionIdentifier: {sourceColumn: record_revision} lifecycleState: {sourceColumn: lifecycle_state, codelist: codelists/record-lifecycle.yaml} recordedAt: {sourceColumn: recorded_at} + sourceColumnClassifications: + public_legal_name: {privacy: potentially-personal, institutional: public-by-law, handling: public, status: reviewed} + registrar_legal_name: {privacy: potentially-personal, institutional: public-by-law, handling: confidential, status: reviewed} + registrar_note: {privacy: non-personal, institutional: internal, handling: confidential, status: reviewed} properties: registrationNumber: sourceColumn: registration_number @@ -68,13 +72,29 @@ resources: label: Registration number description: Stable synthetic business registration number. legalName: - sourceColumn: legal_name + sourceColumn: public_legal_name type: string sourceRequired: true semanticTerm: local:legalName label: Legal name description: Registered legal name of the synthetic organisation. classification: {privacy: potentially-personal, institutional: public-by-law} + registrarLegalName: + sourceColumn: registrar_legal_name + type: string + sourceRequired: true + semanticTerm: local:registrarLegalName + label: Registrar legal name + description: Protected authoritative legal name for registrar work. + classification: {privacy: potentially-personal, institutional: public-by-law, handling: confidential, status: reviewed} + registrarNote: + sourceColumn: registrar_note + type: string + sourceRequired: true + semanticTerm: local:registrarNote + label: Registrar note + description: Protected synthetic registrar-only note. + classification: {privacy: non-personal, institutional: internal, handling: confidential, status: reviewed} registrationStatus: sourceColumn: registration_status type: controlled-code @@ -102,10 +122,16 @@ resources: disclosureProfiles: public-register: properties: [registrationNumber, legalName, registrationStatus, legalForm, registeredJurisdiction] + registrar-register: + properties: [registrationNumber, registrarLegalName, registrarNote, registrationStatus, legalForm, registeredJurisdiction] operations: list: - access: public - disclosureProfile: public-register + defaultRepresentation: public-register + representations: + public-register: {access: public, disclosureProfile: public-register} + registrar: + access: {scope: registry:business:list-registrar} + disclosureProfile: registrar-register filters: - {name: status, property: registrationStatus, type: controlled-code} - {name: jurisdiction, property: registeredJurisdiction, type: controlled-code} @@ -113,8 +139,12 @@ resources: orderBy: [registrationNumber] pagination: {defaultPageSize: 2, maximumPageSize: 4} read: - access: public - disclosureProfile: public-register + defaultRepresentation: public-register + representations: + public-register: {access: public, disclosureProfile: public-register} + registrar: + access: {scope: registry:business:read-registrar} + disclosureProfile: registrar-register processingDescriptions: - id: statutory-publication operationRefs: [list, read] diff --git a/products/relay-v2/acceptance/business-registry/runtime.yaml b/products/relay-v2/acceptance/business-registry/runtime.yaml index 9bcd7745b..f318879a7 100644 --- a/products/relay-v2/acceptance/business-registry/runtime.yaml +++ b/products/relay-v2/acceptance/business-registry/runtime.yaml @@ -7,7 +7,12 @@ sources: companies: path: fixture.sqlite authentication: - issuer: null + issuer: + id: synthetic-business-issuer + discoveryUrl: https://identity.example.invalid/.well-known/openid-configuration + audience: relay-business-registry + tokenTypes: [at+jwt] + algorithms: [ES256] audit: sink: var/audit.jsonl integrityKeyRef: secret:env/RELAY_TEST_AUDIT_KEY diff --git a/products/relay-v2/acceptance/civil-event/expected-http.yaml b/products/relay-v2/acceptance/civil-event/expected-http.yaml index 50809c807..950620d2e 100644 --- a/products/relay-v2/acceptance/civil-event/expected-http.yaml +++ b/products/relay-v2/acceptance/civil-event/expected-http.yaml @@ -9,6 +9,10 @@ authorizations: principal: synthetic-verifier-client scopes: [registry:civil-events:lookup] claims: {purpose: registration-verification, jurisdiction: EX-A} + civil-supervisor-ex-a: + principal: synthetic-supervisory-client + scopes: [registry:civil-events:supervisory] + claims: {purpose: registration-supervision, jurisdiction: EX-A} civil-verifier-wrong-purpose: principal: synthetic-verifier-client scopes: [registry:civil-events:lookup] @@ -66,12 +70,40 @@ steps: request: method: POST path: /v2/resources/civil-event/lookups/verify-registration - query: {fields: "eventType,registrationStatus,certificateAvailable"} + query: {fields: "eventType,registrationStatus,registrationDate,certificateAvailable"} body: {registrationNumber: REG-SYNTH-000001, eventType: BIRTH} expect: status: 200 registryCoreRequired: true - domainDataKeys: [eventType, registrationStatus, certificateAvailable] + domainDataKeys: [eventType, registrationStatus, registrationDate, certificateAvailable] + - id: supervisory-date-precision + authorizationFixture: civil-supervisor-ex-a + request: + method: POST + path: /v2/resources/civil-event/lookups/verify-registration + query: {representation: supervisory, fields: "eventType,registrationYear"} + body: {registrationNumber: REG-SYNTH-000001, eventType: BIRTH} + expect: + status: 200 + registryCoreRequired: true + domainDataKeys: [eventType, registrationYear] + cache: no-store + - id: supervisory-representation-denied + authorizationFixture: civil-verifier-ex-a + request: + method: POST + path: /v2/resources/civil-event/lookups/verify-registration + query: {representation: supervisory} + body: {registrationNumber: REG-SYNTH-000001, eventType: BIRTH} + expect: {status: 403, code: consultation.denied} + - id: invalid-representation + authorizationFixture: civil-verifier-ex-a + request: + method: POST + path: /v2/resources/civil-event/lookups/verify-registration + query: {representation: invalid} + body: {registrationNumber: REG-SYNTH-000001, eventType: BIRTH} + expect: {status: 404, code: representation.not_found} - id: no-list authorizationFixture: civil-registrar-ex-a request: {method: GET, path: /v2/resources/civil-event/records} @@ -161,6 +193,14 @@ steps: path: /v2/resources/civil-event/lookups/verify-registration body: {registrationNumber: REG-SYNTH-INVALID1, eventType: BIRTH} expect: {status: 404, code: consultation.unresolved, equivalenceClass: unresolved} + - id: invalid-transform-input + authorizationFixture: civil-supervisor-ex-a + request: + method: POST + path: /v2/resources/civil-event/lookups/verify-registration + query: {representation: supervisory} + body: {registrationNumber: REG-SYNTH-XFORM1, eventType: BIRTH} + expect: {status: 404, code: consultation.unresolved, equivalenceClass: unresolved} - id: quota-exhausted authorizationFixture: civil-verifier-ex-a request: diff --git a/products/relay-v2/acceptance/civil-event/fixture.sql b/products/relay-v2/acceptance/civil-event/fixture.sql index dc64a56a8..0df9fc940 100644 --- a/products/relay-v2/acceptance/civil-event/fixture.sql +++ b/products/relay-v2/acceptance/civil-event/fixture.sql @@ -19,7 +19,8 @@ INSERT INTO source_civil_events VALUES ('EVENT-SYNTH-0002', '2', 'ACTIVE', '2026-05-02T10:00:00Z', 'DEATH', 'REGISTERED', '2026-05-01', 'AREA-B', 1, 'EX-B', 'REG-SYNTH-000002'), ('EVENT-SYNTH-0101', '1', 'ACTIVE', '2026-05-03T10:00:00Z', 'BIRTH', 'REGISTERED', '2026-05-02', 'AREA-A', 1, 'EX-A', 'REG-SYNTH-AMBIG01'), ('EVENT-SYNTH-0102', '1', 'ACTIVE', '2026-05-03T10:05:00Z', 'BIRTH', 'REGISTERED', '2026-05-02', 'AREA-A', 0, 'EX-A', 'REG-SYNTH-AMBIG01'), -('EVENT-SYNTH-BAD1', '1', 'NOT-A-LIFECYCLE', '2026-05-04T10:00:00Z', 'BIRTH', 'REGISTERED', '2026-05-03', 'AREA-A', 1, 'EX-A', 'REG-SYNTH-INVALID1'); +('EVENT-SYNTH-BAD1', '1', 'NOT-A-LIFECYCLE', '2026-05-04T10:00:00Z', 'BIRTH', 'REGISTERED', '2026-05-03', 'AREA-A', 1, 'EX-A', 'REG-SYNTH-INVALID1'), +('EVENT-SYNTH-XFORM', '1', 'ACTIVE', '2026-05-05T10:00:00Z', 'BIRTH', 'REGISTERED', 'not-a-date', 'AREA-A', 1, 'EX-A', 'REG-SYNTH-XFORM1'); CREATE VIEW relay_civil_events AS SELECT event_reference, diff --git a/products/relay-v2/acceptance/civil-event/governance/classification-review-rationale.md b/products/relay-v2/acceptance/civil-event/governance/classification-review-rationale.md new file mode 100644 index 000000000..603b96863 --- /dev/null +++ b/products/relay-v2/acceptance/civil-event/governance/classification-review-rationale.md @@ -0,0 +1,4 @@ +# Classification review rationale + +The supervisory year-precision property is a distinct reviewed output. Exact +lookup remains protected and no collection representation is authorized. diff --git a/products/relay-v2/acceptance/civil-event/governance/classification-review.yaml b/products/relay-v2/acceptance/civil-event/governance/classification-review.yaml new file mode 100644 index 000000000..efa2d5199 --- /dev/null +++ b/products/relay-v2/acceptance/civil-event/governance/classification-review.yaml @@ -0,0 +1,9 @@ +apiVersion: relay.registrystack.org/classification-review/v1 +kind: ClassificationReview +registryIdentifier: urn:example:registry:civil-events +classificationInventoryDigest: sha256:e5a24dd38da75c806b8e0f5ecf5fd043f5e5661315fab94ba072c40d13bdc32b +method: manual +reviewer: urn:example:institution:civil-registration-authority +reviewDate: 2026-08-10 +status: reviewed +rationaleRef: governance/classification-review-rationale.md diff --git a/products/relay-v2/acceptance/civil-event/registry.yaml b/products/relay-v2/acceptance/civil-event/registry.yaml index 1e58b8153..f193e31d5 100644 --- a/products/relay-v2/acceptance/civil-event/registry.yaml +++ b/products/relay-v2/acceptance/civil-event/registry.yaml @@ -39,7 +39,7 @@ classifications: privacy: {scheme: https://w3id.org/dpv, version: "2.3"} institutional: {scheme: https://civil-registry.example.invalid/classification, version: "1"} handling: {scheme: https://id.registrystack.org/vocab/handling, version: "1"} - provenanceRef: governance/legal-basis.yaml + provenanceRef: governance/classification-review.yaml sources: events: kind: sqlite @@ -57,6 +57,7 @@ resources: sourceColumnClassifications: jurisdiction_code: {privacy: personal-context} registration_number: {privacy: identifying} + registration_date: {privacy: personal, institutional: restricted, handling: restricted, status: reviewed} recordContext: recordIdentifier: {sourceColumn: event_reference} revisionIdentifier: {sourceColumn: record_revision} @@ -95,6 +96,15 @@ resources: semanticTerm: local:registrationDate label: Registration date description: Date the synthetic event was registered. + registrationYear: + sourceColumn: registration_date + transform: {kind: date-precision, sourceType: date, precision: year} + type: year + sourceRequired: true + semanticTerm: local:registrationYear + label: Registration year + description: Reviewed year-precision form of the civil-event registration date. + classification: {privacy: derived, institutional: confidential, handling: confidential, status: reviewed} registrationArea: sourceColumn: registration_area_code type: controlled-code @@ -115,34 +125,40 @@ resources: registrar-record: properties: [eventReference, eventType, registrationStatus, registrationDate, registrationArea, certificateAvailable] verification-result: - properties: [eventReference, eventType, registrationStatus, certificateAvailable] + properties: [eventReference, eventType, registrationStatus, registrationDate, certificateAvailable] + supervisory-verification: + properties: [eventReference, eventType, registrationStatus, registrationYear, certificateAvailable] operations: read: - access: - scope: registry:civil-events:read - purpose: - claim: purpose - allowed: [civil-registration-administration] - authorityRowBinding: - claim: jurisdiction - sourceColumn: jurisdiction_code - disclosureProfile: registrar-record + defaultRepresentation: registrar + representations: + registrar: + access: + scope: registry:civil-events:read + purpose: {claim: purpose, allowed: [civil-registration-administration]} + authorityRowBinding: {claim: jurisdiction, sourceColumn: jurisdiction_code} + disclosureProfile: registrar-record lookups: - id: verify-registration - access: - scope: registry:civil-events:lookup - purpose: - claim: purpose - allowed: [registration-verification] - authorityRowBinding: - claim: jurisdiction - sourceColumn: jurisdiction_code requestBody: maximumBytes: 384 selectors: registrationNumber: {sourceColumn: registration_number, type: string, minimumBytes: 12, maximumBytes: 96} eventType: {sourceColumn: event_type, type: controlled-code, codelist: codelists/civil-event-types.yaml} - disclosureProfile: verification-result + defaultRepresentation: registrar-verification + representations: + registrar-verification: + access: + scope: registry:civil-events:lookup + purpose: {claim: purpose, allowed: [registration-verification]} + authorityRowBinding: {claim: jurisdiction, sourceColumn: jurisdiction_code} + disclosureProfile: verification-result + supervisory: + access: + scope: registry:civil-events:supervisory + purpose: {claim: purpose, allowed: [registration-supervision]} + authorityRowBinding: {claim: jurisdiction, sourceColumn: jurisdiction_code} + disclosureProfile: supervisory-verification processingDescriptions: - id: registrar-administration operationRefs: [read] diff --git a/products/relay-v2/acceptance/civil-event/runtime.yaml b/products/relay-v2/acceptance/civil-event/runtime.yaml index 1ff644ef7..e904592a0 100644 --- a/products/relay-v2/acceptance/civil-event/runtime.yaml +++ b/products/relay-v2/acceptance/civil-event/runtime.yaml @@ -21,4 +21,4 @@ limits: concurrentQueries: 16 quotas: requestsPerMinute: 1 - burst: 6 + burst: 8 diff --git a/products/relay-v2/acceptance/social-assistance/expected-http.yaml b/products/relay-v2/acceptance/social-assistance/expected-http.yaml index 05ce1c87b..ae348a717 100644 --- a/products/relay-v2/acceptance/social-assistance/expected-http.yaml +++ b/products/relay-v2/acceptance/social-assistance/expected-http.yaml @@ -3,23 +3,31 @@ registry: urn:example:registry:social-assistance-enrolments authorizations: social-lookup-area-a: principal: synthetic-social-client - scopes: [registry:social-assistance:lookup] + scopes: [registry:social-assistance:limited] + claims: {purpose: benefit-delivery, service_area: AREA-A} + social-caseworker-area-a: + principal: synthetic-social-caseworker + scopes: [registry:social-assistance:caseworker] + claims: {purpose: benefit-delivery, service_area: AREA-A} + social-caseworker-wrong-scope: + principal: synthetic-social-client + scopes: [registry:social-assistance:limited] claims: {purpose: benefit-delivery, service_area: AREA-A} social-lookup-wrong-purpose: principal: synthetic-social-client - scopes: [registry:social-assistance:lookup] + scopes: [registry:social-assistance:limited] claims: {purpose: unpermitted-purpose, service_area: AREA-A} social-lookup-missing-purpose: principal: synthetic-social-client - scopes: [registry:social-assistance:lookup] + scopes: [registry:social-assistance:limited] claims: {service_area: AREA-A} social-lookup-missing-binding: principal: synthetic-social-client - scopes: [registry:social-assistance:lookup] + scopes: [registry:social-assistance:limited] claims: {purpose: benefit-delivery} social-lookup-wrong-binding: principal: synthetic-social-client - scopes: [registry:social-assistance:lookup] + scopes: [registry:social-assistance:limited] claims: {purpose: benefit-delivery, service_area: AREA-B} steps: - id: readiness @@ -46,15 +54,50 @@ steps: expect: status: 200 registryCoreRequired: true - domainDataKeys: [enrolmentReference, programmeCode, enrolmentStatus, validThrough] + domainDataKeys: [maskedEnrolmentReference, enrolmentStatus, validThrough] + - id: caseworker-representation + authorizationFixture: social-caseworker-area-a + request: + method: POST + path: /v2/resources/assistance-enrolment/lookups/by-case-and-person + query: {representation: caseworker, fields: "enrolmentReference,programmeCode"} + body: {caseReference: CASE-SYNTH-0001, personReference: PERSON-SYNTH-0001} + expect: + status: 200 + registryCoreRequired: true + domainDataKeys: [enrolmentReference, programmeCode] + - id: unauthorized-representation + authorizationFixture: social-caseworker-wrong-scope + request: + method: POST + path: /v2/resources/assistance-enrolment/lookups/by-case-and-person + query: {representation: caseworker} + body: {caseReference: CASE-SYNTH-0001, personReference: PERSON-SYNTH-0001} + expect: {status: 403, code: consultation.denied} + - id: unknown-representation + authorizationFixture: social-lookup-area-a + request: + method: POST + path: /v2/resources/assistance-enrolment/lookups/by-case-and-person + query: {representation: unknown-profile} + body: {caseReference: CASE-SYNTH-0001, personReference: PERSON-SYNTH-0001} + expect: {status: 404, code: representation.not_found} + - id: duplicate-representation + authorizationFixture: social-lookup-area-a + request: + method: POST + path: /v2/resources/assistance-enrolment/lookups/by-case-and-person + query: {representation: "limited,caseworker"} + body: {caseReference: CASE-SYNTH-0001, personReference: PERSON-SYNTH-0001} + expect: {status: 400, code: request.representation_invalid} - id: lookup-second-subset authorizationFixture: social-lookup-area-a request: method: POST path: /v2/resources/assistance-enrolment/lookups/by-case-and-person - query: {fields: programmeCode} + query: {fields: maskedEnrolmentReference} body: {caseReference: CASE-SYNTH-0001, personReference: PERSON-SYNTH-0001} - expect: {status: 200, registryCoreRequired: true, domainDataKeys: [programmeCode]} + expect: {status: 200, registryCoreRequired: true, domainDataKeys: [maskedEnrolmentReference]} - id: lookup-jsonld authorizationFixture: social-lookup-area-a request: @@ -167,6 +210,13 @@ steps: path: /v2/resources/assistance-enrolment/lookups/by-case-and-person body: {caseReference: CASE-SYNTH-BAD2, personReference: PERSON-SYNTH-BAD2} expect: {status: 404, code: consultation.unresolved, equivalenceClass: unresolved} + - id: invalid-transform-input + authorizationFixture: social-lookup-area-a + request: + method: POST + path: /v2/resources/assistance-enrolment/lookups/by-case-and-person + body: {caseReference: CASE-SYNTH-XFORM, personReference: PERSON-SYNTH-XFORM} + expect: {status: 404, code: consultation.unresolved, equivalenceClass: unresolved} - id: quota-exhausted authorizationFixture: social-lookup-area-a request: diff --git a/products/relay-v2/acceptance/social-assistance/fixture.sql b/products/relay-v2/acceptance/social-assistance/fixture.sql index 94b550aa9..e4e820e11 100644 --- a/products/relay-v2/acceptance/social-assistance/fixture.sql +++ b/products/relay-v2/acceptance/social-assistance/fixture.sql @@ -2,6 +2,7 @@ PRAGMA foreign_keys = ON; CREATE TABLE source_assistance_enrolments ( enrolment_reference TEXT PRIMARY KEY NOT NULL, + masked_enrolment_reference TEXT NOT NULL, record_revision TEXT NOT NULL, lifecycle_state TEXT NOT NULL, recorded_at TEXT NOT NULL, @@ -14,15 +15,17 @@ CREATE TABLE source_assistance_enrolments ( ) STRICT; INSERT INTO source_assistance_enrolments VALUES -('ENROL-SYNTH-0001', '3', 'ACTIVE', '2026-07-01T09:00:00Z', 'PROGRAMME-A', 'ELIGIBLE', '2026-12-31', 'AREA-A', 'CASE-SYNTH-0001', 'PERSON-SYNTH-0001'), -('ENROL-SYNTH-0002', '2', 'SUSPENDED', '2026-07-02T09:00:00Z', 'PROGRAMME-B', 'SUSPENDED', NULL, 'AREA-B', 'CASE-SYNTH-0002', 'PERSON-SYNTH-0002'), -('ENROL-SYNTH-0101', '1', 'ACTIVE', '2026-07-03T09:00:00Z', 'PROGRAMME-A', 'ELIGIBLE', '2026-12-31', 'AREA-A', 'CASE-SYNTH-AMBIG', 'PERSON-SYNTH-AMBIG'), -('ENROL-SYNTH-0102', '1', 'ACTIVE', '2026-07-03T09:05:00Z', 'PROGRAMME-B', 'ELIGIBLE', '2026-12-31', 'AREA-A', 'CASE-SYNTH-AMBIG', 'PERSON-SYNTH-AMBIG'), -('ENROL-SYNTH-BAD1', '', 'ACTIVE', '2026-07-04T09:00:00Z', 'PROGRAMME-A', 'ELIGIBLE', '2026-12-31', 'AREA-A', 'CASE-SYNTH-BAD1', 'PERSON-SYNTH-BAD1'), -('XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', '1', 'ACTIVE', '2026-07-05T09:00:00Z', 'PROGRAMME-A', 'ELIGIBLE', '2026-12-31', 'AREA-A', 'CASE-SYNTH-BAD2', 'PERSON-SYNTH-BAD2'); +('ENROL-SYNTH-0001', 'ENROL-SYNTH-0001', '3', 'ACTIVE', '2026-07-01T09:00:00Z', 'PROGRAMME-A', 'ELIGIBLE', '2026-12-31', 'AREA-A', 'CASE-SYNTH-0001', 'PERSON-SYNTH-0001'), +('ENROL-SYNTH-0002', 'ENROL-SYNTH-0002', '2', 'SUSPENDED', '2026-07-02T09:00:00Z', 'PROGRAMME-B', 'SUSPENDED', NULL, 'AREA-B', 'CASE-SYNTH-0002', 'PERSON-SYNTH-0002'), +('ENROL-SYNTH-0101', 'ENROL-SYNTH-0101', '1', 'ACTIVE', '2026-07-03T09:00:00Z', 'PROGRAMME-A', 'ELIGIBLE', '2026-12-31', 'AREA-A', 'CASE-SYNTH-AMBIG', 'PERSON-SYNTH-AMBIG'), +('ENROL-SYNTH-0102', 'ENROL-SYNTH-0102', '1', 'ACTIVE', '2026-07-03T09:05:00Z', 'PROGRAMME-B', 'ELIGIBLE', '2026-12-31', 'AREA-A', 'CASE-SYNTH-AMBIG', 'PERSON-SYNTH-AMBIG'), +('ENROL-SYNTH-BAD1', 'ENROL-SYNTH-BAD1', '', 'ACTIVE', '2026-07-04T09:00:00Z', 'PROGRAMME-A', 'ELIGIBLE', '2026-12-31', 'AREA-A', 'CASE-SYNTH-BAD1', 'PERSON-SYNTH-BAD1'), +('XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', '1', 'ACTIVE', '2026-07-05T09:00:00Z', 'PROGRAMME-A', 'ELIGIBLE', '2026-12-31', 'AREA-A', 'CASE-SYNTH-BAD2', 'PERSON-SYNTH-BAD2'), +('ENROL-SYNTH-XFORM', replace(hex(zeroblob(2050)), '0', 'A'), '1', 'ACTIVE', '2026-07-06T09:00:00Z', 'PROGRAMME-A', 'ELIGIBLE', '2026-12-31', 'AREA-A', 'CASE-SYNTH-XFORM', 'PERSON-SYNTH-XFORM'); CREATE VIEW relay_assistance_enrolments AS SELECT enrolment_reference, + masked_enrolment_reference, record_revision, lifecycle_state, recorded_at, diff --git a/products/relay-v2/acceptance/social-assistance/governance/classification-review-rationale.md b/products/relay-v2/acceptance/social-assistance/governance/classification-review-rationale.md new file mode 100644 index 000000000..b7d096ef8 --- /dev/null +++ b/products/relay-v2/acceptance/social-assistance/governance/classification-review-rationale.md @@ -0,0 +1,5 @@ +# Classification review rationale + +The reviewed limited and caseworker representations are necessary for bounded +benefit-delivery consultation. The masked reference remains identifying and +the raw reference, selectors, and service-area binding remain restricted. diff --git a/products/relay-v2/acceptance/social-assistance/governance/classification-review.yaml b/products/relay-v2/acceptance/social-assistance/governance/classification-review.yaml new file mode 100644 index 000000000..d4b9af147 --- /dev/null +++ b/products/relay-v2/acceptance/social-assistance/governance/classification-review.yaml @@ -0,0 +1,16 @@ +apiVersion: relay.registrystack.org/classification-review/v1 +kind: ClassificationReview +registryIdentifier: urn:example:registry:social-assistance-enrolments +classificationInventoryDigest: sha256:2fdc2a749325762d815001b7afa8f25acbb93b22f3e8ea96a16bc92cb9ff9b03 +method: generated +reviewer: urn:example:institution:social-protection-authority +reviewDate: 2026-08-10 +status: reviewed +rationaleRef: governance/classification-review-rationale.md +generatedIdentification: + reportRef: reports/identification-report.json + reportDigest: sha256:4570e2e7b4de293f8ef33ca2661c8b3a524c671558324ca7923f306c35bcd9b3 + rulePack: + id: registrystack.relay.identification.core + version: '1' + digest: sha256:5ad3abd1615c409741c190ff17c4ad8cf31db88dfe49c9bbddf47a2e12896fa2 diff --git a/products/relay-v2/acceptance/social-assistance/registry.yaml b/products/relay-v2/acceptance/social-assistance/registry.yaml index 4cca449d6..414b9787d 100644 --- a/products/relay-v2/acceptance/social-assistance/registry.yaml +++ b/products/relay-v2/acceptance/social-assistance/registry.yaml @@ -34,12 +34,12 @@ classifications: privacy: {scheme: https://w3id.org/dpv, version: "2.3"} institutional: {scheme: https://social-registry.example.invalid/classification, version: "1"} handling: {scheme: https://id.registrystack.org/vocab/handling, version: "1"} - provenanceRef: governance/legal-basis.yaml + provenanceRef: governance/classification-review.yaml sources: assistance: kind: sqlite profile: live-read-only - expectedSchemaFingerprint: sha256:256b74415d1da84efee8fecd3c59e4cebbbc99f43d77e3aa8b75e17d3b3a71d1 + expectedSchemaFingerprint: sha256:936a90a03d06be67a76226d6999a830c04f6604a3ff8b340a62fdd378d8c6d91 resources: - id: assistance-enrolment title: Assistance enrolment @@ -50,6 +50,8 @@ resources: view: relay_assistance_enrolments classificationDefaults: {privacy: sensitive-personal, institutional: restricted, handling: restricted, status: reviewed} sourceColumnClassifications: + enrolment_reference: {privacy: identifying, institutional: restricted, handling: restricted, status: reviewed} + masked_enrolment_reference: {privacy: identifying, institutional: restricted, handling: restricted, status: reviewed} case_reference: {privacy: identifying} person_reference: {privacy: identifying} service_area_code: {privacy: personal-context} @@ -67,6 +69,15 @@ resources: label: Enrolment reference description: Stable synthetic enrolment reference. classification: {privacy: identifying} + maskedEnrolmentReference: + sourceColumn: masked_enrolment_reference + transform: {kind: partial-string, reveal: suffix, characters: 4} + type: string + sourceRequired: true + semanticTerm: local:maskedEnrolmentReference + label: Masked enrolment reference + description: Relay-owned partial-string view with only the final four Unicode scalars visible. + classification: {privacy: partially-revealed-identifying, institutional: confidential, handling: confidential, status: reviewed} programmeCode: sourceColumn: programme_code type: controlled-code @@ -92,25 +103,32 @@ resources: description: Last date on which the status is valid when bounded. classification: {privacy: personal} disclosureProfiles: - consultation: + limited: + properties: [maskedEnrolmentReference, enrolmentStatus, validThrough] + caseworker: properties: [enrolmentReference, programmeCode, enrolmentStatus, validThrough] operations: lookups: - id: by-case-and-person - access: - scope: registry:social-assistance:lookup - purpose: - claim: purpose - allowed: [benefit-delivery] - authorityRowBinding: - claim: service_area - sourceColumn: service_area_code requestBody: maximumBytes: 512 selectors: caseReference: {sourceColumn: case_reference, type: string, minimumBytes: 8, maximumBytes: 96} personReference: {sourceColumn: person_reference, type: string, minimumBytes: 8, maximumBytes: 96} - disclosureProfile: consultation + defaultRepresentation: limited + representations: + limited: + access: + scope: registry:social-assistance:limited + purpose: {claim: purpose, allowed: [benefit-delivery]} + authorityRowBinding: {claim: service_area, sourceColumn: service_area_code} + disclosureProfile: limited + caseworker: + access: + scope: registry:social-assistance:caseworker + purpose: {claim: purpose, allowed: [benefit-delivery]} + authorityRowBinding: {claim: service_area, sourceColumn: service_area_code} + disclosureProfile: caseworker processingDescriptions: - id: benefit-delivery-consultation operationRefs: [lookup:by-case-and-person] diff --git a/products/relay-v2/acceptance/social-assistance/reports/identification-report.json b/products/relay-v2/acceptance/social-assistance/reports/identification-report.json new file mode 100644 index 000000000..ffbe6139a --- /dev/null +++ b/products/relay-v2/acceptance/social-assistance/reports/identification-report.json @@ -0,0 +1 @@ +{"apiVersion":"relay.registrystack.org/identification-report/v1","candidates":[{"confidence":"weak","matchedRules":[{"family":"columns","id":"core.column.fallback","version":"1"}],"rulePack":{"digest":"sha256:5ad3abd1615c409741c190ff17c4ad8cf31db88dfe49c9bbddf47a2e12896fa2","id":"registrystack.relay.identification.core","version":"1"},"source":"assistance","sourceColumn":"case_reference","status":"suggested","suggestedPrivacy":[],"suggestedProperty":"caseReference","suggestedRole":"property","suggestedSemanticTerm":"local:caseReference","view":"relay_assistance_enrolments"},{"confidence":"exact","matchedRules":[{"family":"identifiers","id":"core.role.record-identifier","version":"1"}],"rulePack":{"digest":"sha256:5ad3abd1615c409741c190ff17c4ad8cf31db88dfe49c9bbddf47a2e12896fa2","id":"registrystack.relay.identification.core","version":"1"},"source":"assistance","sourceColumn":"enrolment_reference","status":"suggested","suggestedPrivacy":[],"suggestedProperty":"enrolmentReference","suggestedRole":"record-identifier","suggestedSemanticTerm":"local:enrolmentReference","view":"relay_assistance_enrolments"},{"confidence":"exact","matchedRules":[{"family":"codelists","id":"core.role.codelist","version":"1"}],"rulePack":{"digest":"sha256:5ad3abd1615c409741c190ff17c4ad8cf31db88dfe49c9bbddf47a2e12896fa2","id":"registrystack.relay.identification.core","version":"1"},"source":"assistance","sourceColumn":"enrolment_status","status":"suggested","suggestedPrivacy":[],"suggestedProperty":"enrolmentStatus","suggestedRole":"codelist","suggestedSemanticTerm":"local:enrolmentStatus","view":"relay_assistance_enrolments"},{"confidence":"exact","matchedRules":[{"family":"lifecycle","id":"core.name.lifecycle","version":"1"},{"family":"codelists","id":"core.role.codelist","version":"1"},{"family":"lifecycle","id":"core.role.lifecycle-state","version":"1"}],"rulePack":{"digest":"sha256:5ad3abd1615c409741c190ff17c4ad8cf31db88dfe49c9bbddf47a2e12896fa2","id":"registrystack.relay.identification.core","version":"1"},"source":"assistance","sourceColumn":"lifecycle_state","status":"suggested","suggestedPrivacy":[],"suggestedProperty":"lifecycleState","suggestedRole":"lifecycle-state","suggestedSemanticTerm":"local:lifecycleState","view":"relay_assistance_enrolments"},{"confidence":"weak","matchedRules":[{"family":"columns","id":"core.column.fallback","version":"1"}],"rulePack":{"digest":"sha256:5ad3abd1615c409741c190ff17c4ad8cf31db88dfe49c9bbddf47a2e12896fa2","id":"registrystack.relay.identification.core","version":"1"},"source":"assistance","sourceColumn":"masked_enrolment_reference","status":"suggested","suggestedPrivacy":[],"suggestedProperty":"maskedEnrolmentReference","suggestedRole":"property","suggestedSemanticTerm":"local:maskedEnrolmentReference","view":"relay_assistance_enrolments"},{"confidence":"strong","matchedRules":[{"family":"person-references","id":"core.name.person-reference","version":"1"}],"rulePack":{"digest":"sha256:5ad3abd1615c409741c190ff17c4ad8cf31db88dfe49c9bbddf47a2e12896fa2","id":"registrystack.relay.identification.core","version":"1"},"source":"assistance","sourceColumn":"person_reference","status":"suggested","suggestedPrivacy":[{"scheme":"urn:registrystack:relay:privacy-candidate","term":"identifying","version":"1"}],"suggestedProperty":"personReference","suggestedRole":"person-reference","suggestedSemanticTerm":"local:personReference","view":"relay_assistance_enrolments"},{"confidence":"exact","matchedRules":[{"family":"codelists","id":"core.role.codelist","version":"1"}],"rulePack":{"digest":"sha256:5ad3abd1615c409741c190ff17c4ad8cf31db88dfe49c9bbddf47a2e12896fa2","id":"registrystack.relay.identification.core","version":"1"},"source":"assistance","sourceColumn":"programme_code","status":"suggested","suggestedPrivacy":[],"suggestedProperty":"programmeCode","suggestedRole":"codelist","suggestedSemanticTerm":"local:programme","view":"relay_assistance_enrolments"},{"confidence":"exact","matchedRules":[{"family":"revisions","id":"core.role.revision-identifier","version":"1"}],"rulePack":{"digest":"sha256:5ad3abd1615c409741c190ff17c4ad8cf31db88dfe49c9bbddf47a2e12896fa2","id":"registrystack.relay.identification.core","version":"1"},"source":"assistance","sourceColumn":"record_revision","status":"suggested","suggestedPrivacy":[],"suggestedProperty":"recordRevision","suggestedRole":"revision-identifier","suggestedSemanticTerm":"local:recordRevision","view":"relay_assistance_enrolments"},{"confidence":"exact","matchedRules":[{"family":"times","id":"core.name.recorded-time","version":"1"},{"family":"times","id":"core.name.time-suffix","version":"1"},{"family":"times","id":"core.role.recorded-at","version":"1"}],"rulePack":{"digest":"sha256:5ad3abd1615c409741c190ff17c4ad8cf31db88dfe49c9bbddf47a2e12896fa2","id":"registrystack.relay.identification.core","version":"1"},"source":"assistance","sourceColumn":"recorded_at","status":"suggested","suggestedPrivacy":[],"suggestedProperty":"recordedAt","suggestedRole":"recorded-time","suggestedSemanticTerm":"local:recordedAt","view":"relay_assistance_enrolments"},{"confidence":"strong","matchedRules":[{"family":"identifiers","id":"core.role.row-binding","version":"1"}],"rulePack":{"digest":"sha256:5ad3abd1615c409741c190ff17c4ad8cf31db88dfe49c9bbddf47a2e12896fa2","id":"registrystack.relay.identification.core","version":"1"},"source":"assistance","sourceColumn":"service_area_code","status":"suggested","suggestedPrivacy":[{"scheme":"urn:registrystack:relay:privacy-candidate","term":"potentially-personal","version":"1"}],"suggestedProperty":"serviceAreaCode","suggestedRole":"identifier","suggestedSemanticTerm":"local:serviceAreaCode","view":"relay_assistance_enrolments"},{"confidence":"weak","matchedRules":[{"family":"columns","id":"core.column.fallback","version":"1"}],"rulePack":{"digest":"sha256:5ad3abd1615c409741c190ff17c4ad8cf31db88dfe49c9bbddf47a2e12896fa2","id":"registrystack.relay.identification.core","version":"1"},"source":"assistance","sourceColumn":"valid_through","status":"suggested","suggestedPrivacy":[],"suggestedProperty":"validThrough","suggestedRole":"property","suggestedSemanticTerm":"local:validThrough","view":"relay_assistance_enrolments"}],"diagnostics":[],"kind":"IdentificationReport","observedSchemaDigest":"sha256:2e0ac2e10a09b2bab651e5f9f8c4ba27264b59657d2069dd6d034e968231ebd8","privacyCandidateVocabulary":{"scheme":"urn:registrystack:relay:privacy-candidate","version":"1"},"registryIdentifier":"urn:example:registry:social-assistance-enrolments","rulePack":{"digest":"sha256:5ad3abd1615c409741c190ff17c4ad8cf31db88dfe49c9bbddf47a2e12896fa2","id":"registrystack.relay.identification.core","version":"1"}} \ No newline at end of file diff --git a/products/relay-v2/acceptance/social-assistance/runtime.yaml b/products/relay-v2/acceptance/social-assistance/runtime.yaml index 788a7a70e..6f39bcd2e 100644 --- a/products/relay-v2/acceptance/social-assistance/runtime.yaml +++ b/products/relay-v2/acceptance/social-assistance/runtime.yaml @@ -21,4 +21,4 @@ limits: concurrentQueries: 16 quotas: requestsPerMinute: 1 - burst: 10 + burst: 12 diff --git a/products/relay-v2/contracts/acceptance-scenario-matrix.yaml b/products/relay-v2/contracts/acceptance-scenario-matrix.yaml index e189e0d26..c7210474d 100644 --- a/products/relay-v2/contracts/acceptance-scenario-matrix.yaml +++ b/products/relay-v2/contracts/acceptance-scenario-matrix.yaml @@ -5,6 +5,10 @@ scenarios: - {id: social-ready, project: social-assistance, journeyStep: readiness, assertion: One local protected Registry becomes ready from a generated package.} - {id: social-lookup, project: social-assistance, journeyStep: lookup-success, assertion: A bounded protected lookup returns only its selected governed properties.} - {id: social-lookup-default, project: social-assistance, journeyStep: lookup-default, assertion: The default representation contains exactly the compiled disclosure profile.} + - {id: social-caseworker-representation, project: social-assistance, journeyStep: caseworker-representation, assertion: An entitled caseworker explicitly selects its full representation then narrows fields within it.} + - {id: social-unauthorized-representation, project: social-assistance, journeyStep: unauthorized-representation, assertion: A selected caseworker representation is denied without fallback to limited.} + - {id: social-unknown-representation, project: social-assistance, journeyStep: unknown-representation, assertion: An unknown representation is rejected before source access.} + - {id: social-duplicate-representation, project: social-assistance, journeyStep: duplicate-representation, assertion: A malformed representation selection is rejected before source access.} - {id: social-lookup-second-subset, project: social-assistance, journeyStep: lookup-second-subset, assertion: A second valid field subset remains governed independently.} - {id: social-lookup-jsonld, project: social-assistance, journeyStep: lookup-jsonld, assertion: JSON and JSON-LD encodings carry equivalent Registry records.} - {id: social-route, project: social-assistance, journeyStep: route-confinement, assertion: An undeclared collection route is absent.} @@ -21,6 +25,7 @@ scenarios: - {id: social-hidden, project: social-assistance, journeyStep: row-hidden, assertion: A row outside the caller binding has the unresolved outcome.} - {id: social-invalid, project: social-assistance, journeyStep: invalid-row, invalidSourceRowClass: missing-required, assertion: An invalid selected row has the unresolved outcome.} - {id: social-excessive, project: social-assistance, journeyStep: excessive-row, invalidSourceRowClass: excessive-size, assertion: An excessively large source value fails closed as unresolved.} + - {id: social-invalid-transform, project: social-assistance, journeyStep: invalid-transform-input, invalidSourceRowClass: excessive-size, assertion: An oversized partial-string source fails closed as unresolved.} - {id: social-quota, project: social-assistance, journeyStep: quota-exhausted, assertion: The named lookup fails closed after its compiled-operation quota is exhausted.} - {id: business-discovery, project: business-registry, journeyStep: registry-discovery, assertion: Discovery derives only the public Consultation capabilities.} - {id: business-first-page, project: business-registry, journeyStep: first-page, assertion: The snapshot list is paginated and has a cursor.} @@ -31,6 +36,9 @@ scenarios: - {id: business-filter, project: business-registry, journeyStep: filtered-page, assertion: Declared exact filters and a selected field subset remain governed.} - {id: business-second-subset, project: business-registry, journeyStep: second-field-subset, assertion: A second valid field subset remains governed independently.} - {id: business-read, project: business-registry, journeyStep: identifier-read, assertion: An identifier read is cacheable under the snapshot posture.} + - {id: business-registrar-read, project: business-registry, journeyStep: registrar-read, assertion: The protected registrar representation is selected explicitly and remains no-store.} + - {id: business-registrar-denied, project: business-registry, journeyStep: registrar-representation-denied, assertion: An unentitled caller cannot select the protected registrar representation.} + - {id: business-public-unknown-representation, project: business-registry, journeyStep: public-representation-unknown, assertion: Public discovery cannot turn an unknown representation into a fallback.} - {id: business-read-jsonld, project: business-registry, journeyStep: identifier-read-jsonld, assertion: JSON and JSON-LD encodings carry equivalent Registry records.} - {id: business-revalidation, project: business-registry, journeyStep: identifier-read-revalidated, assertion: A matching ETag produces an empty 304 response with the same validator.} - {id: business-unknown-field, project: business-registry, journeyStep: unknown-field, assertion: An unknown public property is rejected before source access.} @@ -47,6 +55,9 @@ scenarios: - {id: civil-read-second-subset, project: civil-event, journeyStep: registrar-read-second-subset, assertion: A second valid field subset remains governed independently.} - {id: civil-read-jsonld, project: civil-event, journeyStep: registrar-read-jsonld, assertion: JSON and JSON-LD encodings carry equivalent Registry records.} - {id: civil-lookup, project: civil-event, journeyStep: lookup-success, assertion: The verification scope receives its distinct disclosure profile.} + - {id: civil-supervisory-date-precision, project: civil-event, journeyStep: supervisory-date-precision, assertion: The supervisory representation returns reviewed date-precision output over the same exact lookup.} + - {id: civil-supervisory-denied, project: civil-event, journeyStep: supervisory-representation-denied, assertion: A registrar-verification grant cannot fall back from a denied supervisory representation.} + - {id: civil-invalid-representation, project: civil-event, journeyStep: invalid-representation, assertion: An invalid civil representation is rejected before lookup execution.} - {id: civil-route, project: civil-event, journeyStep: no-list, assertion: A non-enumerable Registry has no collection route.} - {id: civil-scope, project: civil-event, journeyStep: scope-separation, assertion: A lookup scope cannot synthesize identifier read.} - {id: civil-reverse-scope, project: civil-event, journeyStep: reverse-scope-separation, assertion: A registrar read scope cannot synthesize verification lookup.} @@ -60,4 +71,5 @@ scenarios: - {id: civil-no-match, project: civil-event, journeyStep: no-match, assertion: A missing civil-event lookup has the unresolved outcome.} - {id: civil-ambiguous, project: civil-event, journeyStep: ambiguous, assertion: An ambiguous lookup has the unresolved outcome.} - {id: civil-invalid, project: civil-event, journeyStep: invalid-row, invalidSourceRowClass: unexpected-value, assertion: An invalid source row has the unresolved outcome.} + - {id: civil-invalid-transform, project: civil-event, journeyStep: invalid-transform-input, invalidSourceRowClass: unexpected-value, assertion: A noncanonical date-precision source fails closed as unresolved.} - {id: civil-quota, project: civil-event, journeyStep: quota-exhausted, assertion: A distinct named lookup has its own bounded quota and value-free refusal.} diff --git a/products/relay-v2/contracts/artifact-inventory.yaml b/products/relay-v2/contracts/artifact-inventory.yaml index 46d7b5ed9..851802354 100644 --- a/products/relay-v2/contracts/artifact-inventory.yaml +++ b/products/relay-v2/contracts/artifact-inventory.yaml @@ -16,7 +16,13 @@ artifacts: visibility: operation-compatible source: compiled-resource generated: true - invariant: Validates mandatory Registry Core and every allowed domainData subset. + invariant: One artifact exists per compiled operation representation and validates mandatory Registry Core with every allowed selected-profile domainData subset. + - id: representation-shacl + mediaType: text/turtle + visibility: operation-compatible + source: compiled-operation-representation + generated: true + invariant: One shape exists per compiled operation representation; public projection never inventories protected profile identifiers. - id: full-record-schema mediaType: application/schema+json visibility: operator-only @@ -60,3 +66,33 @@ artifacts: visibility: operator-only source: relay-audit-vocabulary generated: true + - id: identification-report + mediaType: application/json + visibility: operator-only + source: schema-observation-and-digest-pinned-rule-pack + generated: true + invariant: Deterministic, schema-only, value-free candidates remain suggested or uncertain. + - id: classification-inventory + mediaType: application/json + visibility: operator-only + source: compiled-classifications + generated: true + invariant: Accounts for every processed source column and disclosed property. + - id: representation-report + mediaType: application/json + visibility: operator-only + source: compiled-operation-representations + generated: true + invariant: Separates per-representation processed columns and processing handling from disclosed properties and disclosure handling. + - id: contextual-review-findings + mediaType: application/json + visibility: operator-only + source: compiled-classification-context + generated: true + invariant: Value-free prompts are review aids, not runtime policy. + - id: classification-review + mediaType: application/yaml + visibility: operator-only + source: reviewed-governance-sidecar + generated: false + invariant: Binds Registry identity and classification inventory; generated review also binds its exact report and rule pack. diff --git a/products/relay-v2/contracts/generated-baselines.yaml b/products/relay-v2/contracts/generated-baselines.yaml index 1e832500e..3ea0db043 100644 --- a/products/relay-v2/contracts/generated-baselines.yaml +++ b/products/relay-v2/contracts/generated-baselines.yaml @@ -2,129 +2,198 @@ schemaVersion: relay.registrystack.org/generated-baselines/v1alpha1 product: relay-v2 projects: social-assistance: - packageRevision: sha256:cf5132c1cc009ced8d958090e940cf6d08ffd3d6a943a6d69dac3dfeb6dd28f8 - contractRevision: sha256:4fc1a040ecb0e1b699ca4872ce4b2295cc889ab1b0a3b898e1221b733da082e2 + packageRevision: sha256:0e97389ed80f35a90d075f3227365cd37acea51a4df0b30957b6c168617d2bf1 + contractRevision: sha256:0fd06f53b937afbb0252715010ff222c4cb8817a6c62648a2a72ac4d35eae282 sourceSchemaFingerprints: - assistance: sha256:256b74415d1da84efee8fecd3c59e4cebbbc99f43d77e3aa8b75e17d3b3a71d1 + assistance: sha256:936a90a03d06be67a76226d6999a830c04f6604a3ff8b340a62fdd378d8c6d91 artifacts: - - id: assistance-enrolment.lookup.by-case-and-person-capability + - id: assistance-enrolment--lookup-by-case-and-person--representation-caseworker-capability mediaType: application/json operationIdentifier: assistance-enrolment.lookup.by-case-and-person - path: generated/artifacts/assistance-enrolment--lookup-by-case-and-person.capability.json - sha256: sha256:02c7457539602dc66db44aef46c01cd90b210867682fd743010f4a2eca0fcd95 + path: generated/artifacts/assistance-enrolment--lookup-by-case-and-person--representation-caseworker.capability.json + representationIdentifier: caseworker + sha256: sha256:1d67b9dace04ce6f30dc845de42fc6f56574f5ff28eda76c55defe46bc1fabcd visibility: operation-bound - - id: assistance-enrolment--lookup-by-case-and-person-classifications + - id: assistance-enrolment--lookup-by-case-and-person--representation-caseworker-classifications mediaType: application/json operationIdentifier: null - path: generated/artifacts/assistance-enrolment--lookup-by-case-and-person.classifications.json - sha256: sha256:e517323c69351197244a2a7ac541ba40fe5f73ed4590b5cbe810773ed04615e1 + path: generated/artifacts/assistance-enrolment--lookup-by-case-and-person--representation-caseworker.classifications.json + representationIdentifier: null + sha256: sha256:d43a82489dd4be38d6176baf09aea8529e2c3d352a819269460305741b590dc2 visibility: operator-only - - id: assistance-enrolment--lookup-by-case-and-person-context + - id: assistance-enrolment--lookup-by-case-and-person--representation-caseworker-context mediaType: application/ld+json operationIdentifier: assistance-enrolment.lookup.by-case-and-person - path: generated/artifacts/assistance-enrolment--lookup-by-case-and-person.context.jsonld + path: generated/artifacts/assistance-enrolment--lookup-by-case-and-person--representation-caseworker.context.jsonld + representationIdentifier: caseworker sha256: sha256:17e5183e5fb37679179920acf100116ffa514857a67796986d0a9f62b39e77d3 visibility: operation-bound - - id: assistance-enrolment--lookup-by-case-and-person-processing + - id: assistance-enrolment--lookup-by-case-and-person--representation-caseworker-processing mediaType: application/json operationIdentifier: assistance-enrolment.lookup.by-case-and-person - path: generated/artifacts/assistance-enrolment--lookup-by-case-and-person.processing.json - sha256: sha256:06d6f78f868ecadb235da94bbb57a0fec7c4ec23a5db23c859a976e542b03d7e + path: generated/artifacts/assistance-enrolment--lookup-by-case-and-person--representation-caseworker.processing.json + representationIdentifier: caseworker + sha256: sha256:09caa79d0afbfc1f133b7d9c551d7b595d0875cbfd1c5dd956b5c1e3a7063216 visibility: operation-bound - - id: assistance-enrolment--lookup-by-case-and-person-schema + - id: assistance-enrolment--lookup-by-case-and-person--representation-caseworker-schema mediaType: application/schema+json operationIdentifier: assistance-enrolment.lookup.by-case-and-person - path: generated/artifacts/assistance-enrolment--lookup-by-case-and-person.schema.json - sha256: sha256:9d6bf2c2193342d763bc562939e784fdacbe1b94d7c9c7210ad52d3db6de69a6 + path: generated/artifacts/assistance-enrolment--lookup-by-case-and-person--representation-caseworker.schema.json + representationIdentifier: caseworker + sha256: sha256:df47a05280064f4e708271690a7906190ada19f1fdc622f38e021c09ba883030 visibility: operation-bound - - id: assistance-enrolment--lookup-by-case-and-person-shacl + - id: assistance-enrolment--lookup-by-case-and-person--representation-caseworker-shacl mediaType: text/turtle operationIdentifier: assistance-enrolment.lookup.by-case-and-person - path: generated/artifacts/assistance-enrolment--lookup-by-case-and-person.shacl.ttl + path: generated/artifacts/assistance-enrolment--lookup-by-case-and-person--representation-caseworker.shacl.ttl + representationIdentifier: caseworker sha256: sha256:8a9552f08b8dcee6a8d40bab5abe9c0f20baf542d537c8731d0c7e0c34da1cb8 visibility: operation-bound - - id: assistance-enrolment--lookup-by-case-and-person-vocabulary + - id: assistance-enrolment--lookup-by-case-and-person--representation-caseworker-vocabulary mediaType: application/ld+json operationIdentifier: assistance-enrolment.lookup.by-case-and-person - path: generated/artifacts/assistance-enrolment--lookup-by-case-and-person.vocabulary.jsonld + path: generated/artifacts/assistance-enrolment--lookup-by-case-and-person--representation-caseworker.vocabulary.jsonld + representationIdentifier: caseworker sha256: sha256:d894822eb794725509df894466e19f0e96caa99e8a612a358cb1dc25b71a1a86 visibility: operation-bound + - id: assistance-enrolment--lookup-by-case-and-person--representation-limited-capability + mediaType: application/json + operationIdentifier: assistance-enrolment.lookup.by-case-and-person + path: generated/artifacts/assistance-enrolment--lookup-by-case-and-person--representation-limited.capability.json + representationIdentifier: limited + sha256: sha256:4f8f10720a121ab9d7355bf214ac03d19c3024312a07e7a079935acaa1c76565 + visibility: operation-bound + - id: assistance-enrolment--lookup-by-case-and-person--representation-limited-classifications + mediaType: application/json + operationIdentifier: null + path: generated/artifacts/assistance-enrolment--lookup-by-case-and-person--representation-limited.classifications.json + representationIdentifier: null + sha256: sha256:dbce084d1bc2bef012da77f20b3e88276308047810b881961c95dfa41c1ba0e7 + visibility: operator-only + - id: assistance-enrolment--lookup-by-case-and-person--representation-limited-context + mediaType: application/ld+json + operationIdentifier: assistance-enrolment.lookup.by-case-and-person + path: generated/artifacts/assistance-enrolment--lookup-by-case-and-person--representation-limited.context.jsonld + representationIdentifier: limited + sha256: sha256:c648dd137278de13ccb21d9b02f0628e8a46fac17327e3fc5ee2b8513da97c9a + visibility: operation-bound + - id: assistance-enrolment--lookup-by-case-and-person--representation-limited-processing + mediaType: application/json + operationIdentifier: assistance-enrolment.lookup.by-case-and-person + path: generated/artifacts/assistance-enrolment--lookup-by-case-and-person--representation-limited.processing.json + representationIdentifier: limited + sha256: sha256:b22930dda04c8043d787bf6979092a89baba41c1772eb26a294523b48b93953e + visibility: operation-bound + - id: assistance-enrolment--lookup-by-case-and-person--representation-limited-schema + mediaType: application/schema+json + operationIdentifier: assistance-enrolment.lookup.by-case-and-person + path: generated/artifacts/assistance-enrolment--lookup-by-case-and-person--representation-limited.schema.json + representationIdentifier: limited + sha256: sha256:8dce934403094548bc7a0666f489d47632834522b53ba61f7082f9e5eb3b807b + visibility: operation-bound + - id: assistance-enrolment--lookup-by-case-and-person--representation-limited-shacl + mediaType: text/turtle + operationIdentifier: assistance-enrolment.lookup.by-case-and-person + path: generated/artifacts/assistance-enrolment--lookup-by-case-and-person--representation-limited.shacl.ttl + representationIdentifier: limited + sha256: sha256:173bb033ad5b00f0edb1769f3815f9eee882292e93510d3e529a2629991395f2 + visibility: operation-bound + - id: assistance-enrolment--lookup-by-case-and-person--representation-limited-vocabulary + mediaType: application/ld+json + operationIdentifier: assistance-enrolment.lookup.by-case-and-person + path: generated/artifacts/assistance-enrolment--lookup-by-case-and-person--representation-limited.vocabulary.jsonld + representationIdentifier: limited + sha256: sha256:d856a45b101cce510ad7ff1d1773f0326e69cd4b52d5ea00fa6534f74d79a881 + visibility: operation-bound - id: assistance-enrolment-classification mediaType: application/json operationIdentifier: null path: generated/artifacts/assistance-enrolment.classifications.json - sha256: sha256:f2eb7d51bbae14ae9b8f8bdf024ea977d0f9fbe7e424c74f928b4dbe1d3d491d + representationIdentifier: null + sha256: sha256:32707dfb3d94080914c914d5bded55741758dfd4e1f2eef49c89e4376042eace visibility: operator-only - id: assistance-enrolment-codelist-0 mediaType: application/schema+json operationIdentifier: null path: generated/artifacts/assistance-enrolment.codelist-0.schema.json + representationIdentifier: null sha256: sha256:a836883fac30ae1cacbd657d7429ff08f9bfff1a3b8abea0bcc4fa5401a7f200 visibility: operator-only - id: assistance-enrolment-codelist-1 mediaType: application/schema+json operationIdentifier: null path: generated/artifacts/assistance-enrolment.codelist-1.schema.json + representationIdentifier: null sha256: sha256:4dd49c40c44f8acbd56f319d4af5c9b48ffee24e5b0bd267f0c6f4833adc73d1 visibility: operator-only - id: assistance-enrolment-codelist-2 mediaType: application/schema+json operationIdentifier: null path: generated/artifacts/assistance-enrolment.codelist-2.schema.json + representationIdentifier: null sha256: sha256:e42dfbcab45a66032d126e0f203523ae44a6bc034278f2ce222f96f1ff0a78f0 visibility: operator-only - id: assistance-enrolment-full-schema mediaType: application/schema+json operationIdentifier: null path: generated/artifacts/assistance-enrolment.full.schema.json - sha256: sha256:c53cb6cfdb9e7abaf220ed7d44cfe47c1889cb1109fbac3b6a6af43af18c8c26 + representationIdentifier: null + sha256: sha256:48bfebf978cd34b49491fbd0e58d535394f1f224ff701b8cb4587305bf66cf26 visibility: operator-only - id: assistance-enrolment-full-shacl mediaType: text/turtle operationIdentifier: null path: generated/artifacts/assistance-enrolment.full.shacl.ttl - sha256: sha256:69790667690544bbebbae987bb733ec45492a0a448902d6c964334d89ab82e34 + representationIdentifier: null + sha256: sha256:0f5bf09cb74494b029e0b8e19ade99d0fe025ec0bee199e03fdd6ae277a1762b visibility: operator-only - id: assistance-enrolment-full-vocabulary mediaType: application/ld+json operationIdentifier: null path: generated/artifacts/assistance-enrolment.full.vocabulary.jsonld - sha256: sha256:d894822eb794725509df894466e19f0e96caa99e8a612a358cb1dc25b71a1a86 + representationIdentifier: null + sha256: sha256:ce35a8374c9a8758f7eb42ed86f77503f2f371ba3da16d17eaa2df1d7a320f92 visibility: operator-only - id: assistance-enrolment-processing-full mediaType: application/json operationIdentifier: null path: generated/artifacts/assistance-enrolment.processing.full.json + representationIdentifier: null sha256: sha256:b3806fac8892ef081c3d8e26ca475fb37c3d318302f593b25828c20110a1f7b5 visibility: operator-only - id: audit-event-schema mediaType: application/schema+json operationIdentifier: null path: generated/artifacts/audit-event.schema.json - sha256: sha256:77b868e58cf3b4e13b739d3f542a018652332fe454781f8efea0962025eccf1b + representationIdentifier: null + sha256: sha256:2b3223ef49813d9b1602317a363a98231978aab34f0b35403c5ef407b6499913 visibility: operator-only - id: capability-inventory-full mediaType: application/json operationIdentifier: null path: generated/artifacts/capabilities.full.json - sha256: sha256:02c7457539602dc66db44aef46c01cd90b210867682fd743010f4a2eca0fcd95 + representationIdentifier: null + sha256: sha256:fba819496d80b2583a996f16f8629c01452011bec2cb9819b6b0327214380b29 visibility: operator-only - id: capability-inventory mediaType: application/json operationIdentifier: null path: generated/artifacts/capabilities.json - sha256: sha256:6e357e9fd5a858f1023b8c503a2b7def06a3eba6d2f600a72e4a31e9302acc5a + representationIdentifier: null + sha256: sha256:521fd460f8b4bde91b2f82299b15b3ae99cb9146a73ed3609df9a7a305d6b163 visibility: public - id: openapi-full mediaType: application/yaml operationIdentifier: null path: generated/openapi.full.yaml - sha256: sha256:58852fc5d65ff3ffcc9c89ff98786575080738ccd0f0d26cd109da58c4357b87 + representationIdentifier: null + sha256: sha256:b7ea4f0e0c1d392b05e8862dbec696d5ced4439d184dc9c9c9b83af3ee842d5b visibility: operator-only - id: openapi-public mediaType: application/json operationIdentifier: null path: generated/openapi.public.json + representationIdentifier: null sha256: sha256:b1a460e09d3d45200f82d9c5f44f5b7fdd2e04db4ee226e716b9104af4573ac8 visibility: public governedFiles: @@ -146,6 +215,18 @@ projects: sha256: sha256:b77eab2bec905fdbb76824fc5ee717c65d4d2c4a77d67cf3a3bcbaf40040d1a4 size: 93 visibility: operator-only + - generated: false + mediaType: application/yaml + path: governed/governance/classification-review-rationale.md + sha256: sha256:0419a970e434e8e8228f42966ebe22ca8d74de52aa85491087d1d41a033c20a3 + size: 263 + visibility: operator-only + - generated: false + mediaType: application/yaml + path: governed/governance/classification-review.yaml + sha256: sha256:7f6e34b231e0b6344a324bf33bf6ea9fed39508789212f0ecef09abea4bcea61 + size: 763 + visibility: operator-only - generated: false mediaType: application/yaml path: governed/governance/identifier-lifecycle.yaml @@ -158,173 +239,303 @@ projects: sha256: sha256:090009737bd9a730e6cf4694e4182fed5778d300885ed48678c7c4628a5c95e5 size: 248 visibility: operator-only + - generated: false + mediaType: application/json + path: governed/reports/identification-report.json + sha256: sha256:4570e2e7b4de293f8ef33ca2661c8b3a524c671558324ca7923f306c35bcd9b3 + size: 6673 + visibility: operator-only - generated: false mediaType: application/yaml path: registry.yaml - sha256: sha256:4e36289929d6a3129239ab8a19f3ca06a146a7fd65655c406b20830695fd62f3 - size: 5222 + sha256: sha256:522486c68a2e4a80798fe8c5291892c2e371391db4a4e0ccf1682ba1ea27b025 + size: 6472 visibility: operator-only business-registry: - packageRevision: sha256:4c264d57df766f2abd8ecfd81941253002059ecc06f33e5374a47c274d3c1ef8 - contractRevision: sha256:152b2794b6e7a59ecd1a4878364f3e4c671e732d522bb4a2c970c87e2aa73fda + packageRevision: sha256:511b9a16ee7985a042a2cda944118d6173ee0f00c98478bf8f3dcc8e33f8fee6 + contractRevision: sha256:6c4ea84a578cf589ffb851722830f90d7c241998d82ea419f7b823640cd056b5 sourceSchemaFingerprints: - companies: sha256:c978e36d7f0de71e8aa8245cdc8501ffdd760d87d9e6e0df97fd1228712361f1 + companies: sha256:5f12cd971dfa9cafd98018bd8723f2c3902e7d96b3c57ecf5c20e4885eb806a8 artifacts: - id: audit-event-schema mediaType: application/schema+json operationIdentifier: null path: generated/artifacts/audit-event.schema.json - sha256: sha256:77b868e58cf3b4e13b739d3f542a018652332fe454781f8efea0962025eccf1b + representationIdentifier: null + sha256: sha256:2b3223ef49813d9b1602317a363a98231978aab34f0b35403c5ef407b6499913 visibility: operator-only - id: capability-inventory-full mediaType: application/json operationIdentifier: null path: generated/artifacts/capabilities.full.json - sha256: sha256:8e8568c166f2b24ee85d9691ba253f94d8bb739116a37e5eb0a0c411cbdf6d92 + representationIdentifier: null + sha256: sha256:fd531b5dc2fa73f708d17dde5edc02b35c56968247eab1e92dc5134efe65c950 visibility: operator-only - id: capability-inventory mediaType: application/json operationIdentifier: null path: generated/artifacts/capabilities.json - sha256: sha256:8e8568c166f2b24ee85d9691ba253f94d8bb739116a37e5eb0a0c411cbdf6d92 + representationIdentifier: null + sha256: sha256:34810616f877e3226d0b3d94fad2c1e412c54d94243e4665a09e98fbcd298785 visibility: public - - id: registered-business--list-classifications + - id: registered-business--list--representation-public-register-classifications mediaType: application/json operationIdentifier: null - path: generated/artifacts/registered-business--list.classifications.json - sha256: sha256:416ca27cf218092bd39993c5cea303c42699a780bfa1c9afc70048fd93d9a5df + path: generated/artifacts/registered-business--list--representation-public-register.classifications.json + representationIdentifier: null + sha256: sha256:83d76093cb7dd51bc482948b403cf3aa051bc4c4fd1308eed7b9a157da5d19af visibility: public - - id: registered-business--list-context + - id: registered-business--list--representation-public-register-context mediaType: application/ld+json operationIdentifier: null - path: generated/artifacts/registered-business--list.context.jsonld + path: generated/artifacts/registered-business--list--representation-public-register.context.jsonld + representationIdentifier: null sha256: sha256:64bac801dca4b23b1179d6d0e024546c51dc0d910348fcb261b8e69b99aeaf41 visibility: public - - id: registered-business--list-processing + - id: registered-business--list--representation-public-register-processing mediaType: application/json operationIdentifier: null - path: generated/artifacts/registered-business--list.processing.json - sha256: sha256:13db0cedf57503ac391dae286d3991500431e43a451b4be28662b98c8492b9e8 + path: generated/artifacts/registered-business--list--representation-public-register.processing.json + representationIdentifier: null + sha256: sha256:c1e61b89e0a67581c8e75da83853d9d1e0dc5c87aa05e6bd0c02b569f71a8c61 visibility: public - - id: registered-business--list-schema + - id: registered-business--list--representation-public-register-schema mediaType: application/schema+json operationIdentifier: null - path: generated/artifacts/registered-business--list.schema.json - sha256: sha256:d2dbbe7436a9e9c6a148d4deb3497fd22912ca48119ed448c577777904e2c773 + path: generated/artifacts/registered-business--list--representation-public-register.schema.json + representationIdentifier: null + sha256: sha256:df98dea2622452d6729268e6d77f2b5bdf5982f2d5f6767189985c6f1dbf870f visibility: public - - id: registered-business--list-shacl + - id: registered-business--list--representation-public-register-shacl mediaType: text/turtle operationIdentifier: null - path: generated/artifacts/registered-business--list.shacl.ttl + path: generated/artifacts/registered-business--list--representation-public-register.shacl.ttl + representationIdentifier: null sha256: sha256:52f7f327c7eaf3b215590b93679e6a7960218d2822f570c9971e8c001bb8706b visibility: public - - id: registered-business--list-vocabulary + - id: registered-business--list--representation-public-register-vocabulary mediaType: application/ld+json operationIdentifier: null - path: generated/artifacts/registered-business--list.vocabulary.jsonld + path: generated/artifacts/registered-business--list--representation-public-register.vocabulary.jsonld + representationIdentifier: null sha256: sha256:24bcf44aa7b04353a8a23b2d80e5c4fe1cf6a60f0b03d0f0a0c48611631ee5d7 visibility: public - - id: registered-business--read-classifications + - id: registered-business--list--representation-registrar-capability + mediaType: application/json + operationIdentifier: registered-business.list + path: generated/artifacts/registered-business--list--representation-registrar.capability.json + representationIdentifier: registrar + sha256: sha256:f102dc91767c0bff76e61dec2f8b948c230025b4b1eb7f2d6af343c2bda7c0a1 + visibility: operation-bound + - id: registered-business--list--representation-registrar-classifications + mediaType: application/json + operationIdentifier: registered-business.list + path: generated/artifacts/registered-business--list--representation-registrar.classifications.json + representationIdentifier: registrar + sha256: sha256:7e3f1278d51e1db709068fec463782892985326bb1b9031197626fa2031319d7 + visibility: operation-bound + - id: registered-business--list--representation-registrar-context + mediaType: application/ld+json + operationIdentifier: registered-business.list + path: generated/artifacts/registered-business--list--representation-registrar.context.jsonld + representationIdentifier: registrar + sha256: sha256:4ded5db266e71750240973f012a644e1282325fc7a6e7fe717100cc79f16f8db + visibility: operation-bound + - id: registered-business--list--representation-registrar-processing + mediaType: application/json + operationIdentifier: registered-business.list + path: generated/artifacts/registered-business--list--representation-registrar.processing.json + representationIdentifier: registrar + sha256: sha256:16edb406f8b2e6fa99e6ad87a8ae64180cc7f7c1006de3060499ddd2a71febb7 + visibility: operation-bound + - id: registered-business--list--representation-registrar-schema + mediaType: application/schema+json + operationIdentifier: registered-business.list + path: generated/artifacts/registered-business--list--representation-registrar.schema.json + representationIdentifier: registrar + sha256: sha256:faf6887e75f88da0552192e58fa918b48b7affebeb1d73268ca40119e24351ba + visibility: operation-bound + - id: registered-business--list--representation-registrar-shacl + mediaType: text/turtle + operationIdentifier: registered-business.list + path: generated/artifacts/registered-business--list--representation-registrar.shacl.ttl + representationIdentifier: registrar + sha256: sha256:a1942ba407f22a4cda0da41e757781270d5fbe7c489c4329f20f73f06e029c0b + visibility: operation-bound + - id: registered-business--list--representation-registrar-vocabulary + mediaType: application/ld+json + operationIdentifier: registered-business.list + path: generated/artifacts/registered-business--list--representation-registrar.vocabulary.jsonld + representationIdentifier: registrar + sha256: sha256:1a1d5fec8194398211a8d8ea6618cef48b291b8d42814d94c5cba9af82d84b36 + visibility: operation-bound + - id: registered-business--read--representation-public-register-classifications mediaType: application/json operationIdentifier: null - path: generated/artifacts/registered-business--read.classifications.json - sha256: sha256:28e9931eb5c876217ddd19245160f77e18afe85bd75d0bead35218a20515a663 + path: generated/artifacts/registered-business--read--representation-public-register.classifications.json + representationIdentifier: null + sha256: sha256:1bb4dc7e114b404dbab14ede384da61f706be65d9f079af0e4bf885c7aa044a4 visibility: public - - id: registered-business--read-context + - id: registered-business--read--representation-public-register-context mediaType: application/ld+json operationIdentifier: null - path: generated/artifacts/registered-business--read.context.jsonld + path: generated/artifacts/registered-business--read--representation-public-register.context.jsonld + representationIdentifier: null sha256: sha256:64bac801dca4b23b1179d6d0e024546c51dc0d910348fcb261b8e69b99aeaf41 visibility: public - - id: registered-business--read-processing + - id: registered-business--read--representation-public-register-processing mediaType: application/json operationIdentifier: null - path: generated/artifacts/registered-business--read.processing.json - sha256: sha256:5b4b29df983773701d8b6731721db7fab64224e849aa1271f5b17133fa5e5e68 + path: generated/artifacts/registered-business--read--representation-public-register.processing.json + representationIdentifier: null + sha256: sha256:a67caa6b24057cf580afa1abe083baaf1d38429ece75d6f2fdada80a86995df4 visibility: public - - id: registered-business--read-schema + - id: registered-business--read--representation-public-register-schema mediaType: application/schema+json operationIdentifier: null - path: generated/artifacts/registered-business--read.schema.json - sha256: sha256:483efcac14157c1277190a97930e64b814ede3718ddc7f9ed41918f3a07a7809 + path: generated/artifacts/registered-business--read--representation-public-register.schema.json + representationIdentifier: null + sha256: sha256:727e234dcfac3cbfe0e9f1924412d4f5d06b9fea2b0ed3a8d5b29747fcdbf5ce visibility: public - - id: registered-business--read-shacl + - id: registered-business--read--representation-public-register-shacl mediaType: text/turtle operationIdentifier: null - path: generated/artifacts/registered-business--read.shacl.ttl + path: generated/artifacts/registered-business--read--representation-public-register.shacl.ttl + representationIdentifier: null sha256: sha256:52f7f327c7eaf3b215590b93679e6a7960218d2822f570c9971e8c001bb8706b visibility: public - - id: registered-business--read-vocabulary + - id: registered-business--read--representation-public-register-vocabulary mediaType: application/ld+json operationIdentifier: null - path: generated/artifacts/registered-business--read.vocabulary.jsonld + path: generated/artifacts/registered-business--read--representation-public-register.vocabulary.jsonld + representationIdentifier: null sha256: sha256:24bcf44aa7b04353a8a23b2d80e5c4fe1cf6a60f0b03d0f0a0c48611631ee5d7 visibility: public + - id: registered-business--read--representation-registrar-capability + mediaType: application/json + operationIdentifier: registered-business.read + path: generated/artifacts/registered-business--read--representation-registrar.capability.json + representationIdentifier: registrar + sha256: sha256:a96f07fbf9b61fed41e01b49d4e6bb5144e568bbbfec53cf9fd8f430c857d643 + visibility: operation-bound + - id: registered-business--read--representation-registrar-classifications + mediaType: application/json + operationIdentifier: registered-business.read + path: generated/artifacts/registered-business--read--representation-registrar.classifications.json + representationIdentifier: registrar + sha256: sha256:a7759fac756bcf40c15e3f3635030f115f25c0971a8c8969721f634b7ed3e5de + visibility: operation-bound + - id: registered-business--read--representation-registrar-context + mediaType: application/ld+json + operationIdentifier: registered-business.read + path: generated/artifacts/registered-business--read--representation-registrar.context.jsonld + representationIdentifier: registrar + sha256: sha256:4ded5db266e71750240973f012a644e1282325fc7a6e7fe717100cc79f16f8db + visibility: operation-bound + - id: registered-business--read--representation-registrar-processing + mediaType: application/json + operationIdentifier: registered-business.read + path: generated/artifacts/registered-business--read--representation-registrar.processing.json + representationIdentifier: registrar + sha256: sha256:8070e0a92f64b68995e1f41e8cf64231425e92471267550173d42ee0104ef1ee + visibility: operation-bound + - id: registered-business--read--representation-registrar-schema + mediaType: application/schema+json + operationIdentifier: registered-business.read + path: generated/artifacts/registered-business--read--representation-registrar.schema.json + representationIdentifier: registrar + sha256: sha256:f816daefa266ce44c6fb945eb6bee2fc6a573d9b7104e5d8c7ee1797eb645efc + visibility: operation-bound + - id: registered-business--read--representation-registrar-shacl + mediaType: text/turtle + operationIdentifier: registered-business.read + path: generated/artifacts/registered-business--read--representation-registrar.shacl.ttl + representationIdentifier: registrar + sha256: sha256:a1942ba407f22a4cda0da41e757781270d5fbe7c489c4329f20f73f06e029c0b + visibility: operation-bound + - id: registered-business--read--representation-registrar-vocabulary + mediaType: application/ld+json + operationIdentifier: registered-business.read + path: generated/artifacts/registered-business--read--representation-registrar.vocabulary.jsonld + representationIdentifier: registrar + sha256: sha256:1a1d5fec8194398211a8d8ea6618cef48b291b8d42814d94c5cba9af82d84b36 + visibility: operation-bound - id: registered-business-classification mediaType: application/json operationIdentifier: null path: generated/artifacts/registered-business.classifications.json - sha256: sha256:ae05d0a676dded7a6f9acfe6d658a6d1bd27de3e427763f31565661f0ccccf28 + representationIdentifier: null + sha256: sha256:0eba6b9824ab9b0e21482bf49cecc6db1401073f2103df2ca57fd93b1383915a visibility: operator-only - id: registered-business-codelist-0 mediaType: application/schema+json operationIdentifier: null path: generated/artifacts/registered-business.codelist-0.schema.json + representationIdentifier: null sha256: sha256:b5f27954974850cd56ec6e271a4f630ce749efc332e58b6a407ece3f943f3d20 visibility: operator-only - id: registered-business-codelist-1 mediaType: application/schema+json operationIdentifier: null path: generated/artifacts/registered-business.codelist-1.schema.json + representationIdentifier: null sha256: sha256:69064b6563a9376270b2d6535a338a5766071012817d25edb0351f8e0e65b76b visibility: operator-only - id: registered-business-codelist-2 mediaType: application/schema+json operationIdentifier: null path: generated/artifacts/registered-business.codelist-2.schema.json + representationIdentifier: null sha256: sha256:4df390c7d6dbf8dae80011b4ea93545b7f2688cc7337a0534f322a92530d3b96 visibility: operator-only - id: registered-business-codelist-3 mediaType: application/schema+json operationIdentifier: null path: generated/artifacts/registered-business.codelist-3.schema.json + representationIdentifier: null sha256: sha256:e42dfbcab45a66032d126e0f203523ae44a6bc034278f2ce222f96f1ff0a78f0 visibility: operator-only - id: registered-business-full-schema mediaType: application/schema+json operationIdentifier: null path: generated/artifacts/registered-business.full.schema.json - sha256: sha256:7832a6823b1e637b63c0f64cdf8e78ed56033321bcdb7de6bd5315dc95bf6d77 + representationIdentifier: null + sha256: sha256:35dbdc7772cdd52829d101e49c18571cf3dbfe8f9737b3a281b9faf3d474a351 visibility: operator-only - id: registered-business-full-shacl mediaType: text/turtle operationIdentifier: null path: generated/artifacts/registered-business.full.shacl.ttl - sha256: sha256:3c78e27835effda7508e45a9d7de364bbf3bd7a00db84bf35ed862c679cc3a18 + representationIdentifier: null + sha256: sha256:6a43f572f14b05968039d17119f534b218eaa649c8a995921f80f47d0b52c098 visibility: operator-only - id: registered-business-full-vocabulary mediaType: application/ld+json operationIdentifier: null path: generated/artifacts/registered-business.full.vocabulary.jsonld - sha256: sha256:24bcf44aa7b04353a8a23b2d80e5c4fe1cf6a60f0b03d0f0a0c48611631ee5d7 + representationIdentifier: null + sha256: sha256:f57ec119ca7d4dc0534ee8e2c5f8756e336f0f90bd34e22fab18f731baffe181 visibility: operator-only - id: registered-business-processing-full mediaType: application/json operationIdentifier: null path: generated/artifacts/registered-business.processing.full.json + representationIdentifier: null sha256: sha256:9e14c3d53958f18e29ee021c74f6f8ea0ceacb0452d01f5f13f5ea7270006158 visibility: operator-only - id: openapi-full mediaType: application/yaml operationIdentifier: null path: generated/openapi.full.yaml - sha256: sha256:d98d08da49feaed0a5d5512cfbf4171ce9c971b92e9591b614e735a8bed06164 + representationIdentifier: null + sha256: sha256:f0f97d965af38fde18f51bdd9aff22199541ff64f51c9c10635fb43c137b4896 visibility: operator-only - id: openapi-public mediaType: application/json operationIdentifier: null path: generated/openapi.public.json - sha256: sha256:d98d08da49feaed0a5d5512cfbf4171ce9c971b92e9591b614e735a8bed06164 + representationIdentifier: null + sha256: sha256:81707ee9bbf12e1ed50f126be4ccb07303decb986d66c2f8ff481c1eeec5a59e visibility: public governedFiles: - generated: false @@ -351,6 +562,18 @@ projects: sha256: sha256:f3f7e339409460ae587ec9ff0d290c08a28cc588060d9a265969f0eb809f9dff size: 95 visibility: operator-only + - generated: false + mediaType: application/yaml + path: governed/governance/classification-review-rationale.md + sha256: sha256:43c20bf9303933e3df2fa6cc753fe80b4aa755c272dda3e67c483c70ffea52cd + size: 227 + visibility: operator-only + - generated: false + mediaType: application/yaml + path: governed/governance/classification-review.yaml + sha256: sha256:01d76e58b426f841c1ea75da053253e2e13dc434957b94ce79323f8f0d9eb3dc + size: 423 + visibility: operator-only - generated: false mediaType: application/yaml path: governed/governance/identifier-lifecycle.yaml @@ -372,12 +595,12 @@ projects: - generated: false mediaType: application/yaml path: registry.yaml - sha256: sha256:0d32692563a1dc6a33ace51f0ff06ee445a74e79357e85339894ad6b8b70257b - size: 5244 + sha256: sha256:6e5d71e5b25bb74e75a9027b044a5292eca0c360bda576952cdd10c3a34d1550 + size: 7020 visibility: operator-only civil-event: - packageRevision: sha256:a93c03f70273fed9061572759f2f447b28a0ad168d976ab5fecde2aa11acddde - contractRevision: sha256:5abb84c832f86b56f24a1e4b96807d0ac2843dbba7c517bafb07fddbf5708964 + packageRevision: sha256:c02ef4ef1c364798ba6dc37fa6c4bc47c78dafbc323046d9ba01db1901ba19d0 + contractRevision: sha256:649c03b1ad1538914fabd918de4617a6ccaa65227ee8d3ebc3b8c385f2348fa7 sourceSchemaFingerprints: events: sha256:7f770d64cb19ec54caca2aa56378b13a43cd5edc206ff44b5fecc99ee9e63759 artifacts: @@ -385,168 +608,245 @@ projects: mediaType: application/schema+json operationIdentifier: null path: generated/artifacts/audit-event.schema.json - sha256: sha256:77b868e58cf3b4e13b739d3f542a018652332fe454781f8efea0962025eccf1b + representationIdentifier: null + sha256: sha256:2b3223ef49813d9b1602317a363a98231978aab34f0b35403c5ef407b6499913 visibility: operator-only - id: capability-inventory-full mediaType: application/json operationIdentifier: null path: generated/artifacts/capabilities.full.json - sha256: sha256:47f000d35d5190ccfb212da6b4a996c9c6a1e0b3f7844a72026933f9c4133535 + representationIdentifier: null + sha256: sha256:a41bd39464a211c0b69c31fa98a0b512b32a093425252951cfa39c2a9f73a2a9 visibility: operator-only - id: capability-inventory mediaType: application/json operationIdentifier: null path: generated/artifacts/capabilities.json - sha256: sha256:8b8fbd251ef0b71de508f3ef87338293d3cd66de0eb9bc370656910cdf49e69b + representationIdentifier: null + sha256: sha256:cd6407aed23bf39ea3dbc8736da2b8d0a2a2f02042fe06f0fd14c73d2756c246 visibility: public - - id: civil-event.lookup.verify-registration-capability + - id: civil-event--lookup-verify-registration--representation-registrar-verification-capability mediaType: application/json operationIdentifier: civil-event.lookup.verify-registration - path: generated/artifacts/civil-event--lookup-verify-registration.capability.json - sha256: sha256:e10860cf67de5f7e2e8bb410abaf026f73dd3dc16af3f4f4cc02549512cb446a + path: generated/artifacts/civil-event--lookup-verify-registration--representation-registrar-verification.capability.json + representationIdentifier: registrar-verification + sha256: sha256:02441c3f6360721b5d81ba497a871d5f5655a002f5b2b87f55f971cd4407cc4a visibility: operation-bound - - id: civil-event--lookup-verify-registration-classifications + - id: civil-event--lookup-verify-registration--representation-registrar-verification-classifications mediaType: application/json operationIdentifier: null - path: generated/artifacts/civil-event--lookup-verify-registration.classifications.json - sha256: sha256:048c5a2ef1a8cfe6c6d9b8a9faf7127387633218e3623986d22fcfa3efdce0a2 + path: generated/artifacts/civil-event--lookup-verify-registration--representation-registrar-verification.classifications.json + representationIdentifier: null + sha256: sha256:1d8d951dac6336b3fd1b1a0842d5f90f98eb5500e932a7ac697365790a4df752 visibility: operator-only - - id: civil-event--lookup-verify-registration-context + - id: civil-event--lookup-verify-registration--representation-registrar-verification-context mediaType: application/ld+json operationIdentifier: civil-event.lookup.verify-registration - path: generated/artifacts/civil-event--lookup-verify-registration.context.jsonld - sha256: sha256:bfa85975a2ced3cbc944a9fb0b025d5ac56f81f68347248b48779da6ca3e45de + path: generated/artifacts/civil-event--lookup-verify-registration--representation-registrar-verification.context.jsonld + representationIdentifier: registrar-verification + sha256: sha256:42c276f75d377fc0b86db9ddef7aff6a987c8dd8b3b035036b367ddc779d5cc6 visibility: operation-bound - - id: civil-event--lookup-verify-registration-processing + - id: civil-event--lookup-verify-registration--representation-registrar-verification-processing mediaType: application/json operationIdentifier: civil-event.lookup.verify-registration - path: generated/artifacts/civil-event--lookup-verify-registration.processing.json - sha256: sha256:5a89ebc0fc172c04171c158e675054cb50334c935fd1d3bd43b387586d945268 + path: generated/artifacts/civil-event--lookup-verify-registration--representation-registrar-verification.processing.json + representationIdentifier: registrar-verification + sha256: sha256:2e9819882cafdef85388ef5775f3c97a9f17fc9b9c4c1fd15e1e156be4593bfd visibility: operation-bound - - id: civil-event--lookup-verify-registration-schema + - id: civil-event--lookup-verify-registration--representation-registrar-verification-schema mediaType: application/schema+json operationIdentifier: civil-event.lookup.verify-registration - path: generated/artifacts/civil-event--lookup-verify-registration.schema.json - sha256: sha256:4b63483e4e1f259bd81ead8dbdbce1107140ede0441ace40d7e377378a0d4994 + path: generated/artifacts/civil-event--lookup-verify-registration--representation-registrar-verification.schema.json + representationIdentifier: registrar-verification + sha256: sha256:4a5f34a0cc8804a3d94b0589a86cf4604bff836fa3bbf38b75efabeac3b526d2 visibility: operation-bound - - id: civil-event--lookup-verify-registration-shacl + - id: civil-event--lookup-verify-registration--representation-registrar-verification-shacl mediaType: text/turtle operationIdentifier: civil-event.lookup.verify-registration - path: generated/artifacts/civil-event--lookup-verify-registration.shacl.ttl - sha256: sha256:feb87a7fb9f5f3b5fb89d0cdd4f95234d8a4a26071bd840779c3c9f04c7f1579 + path: generated/artifacts/civil-event--lookup-verify-registration--representation-registrar-verification.shacl.ttl + representationIdentifier: registrar-verification + sha256: sha256:0198029033b327e73fe5774caa7e1ad887df29d2d63869a45e8ad0e562620c09 visibility: operation-bound - - id: civil-event--lookup-verify-registration-vocabulary + - id: civil-event--lookup-verify-registration--representation-registrar-verification-vocabulary mediaType: application/ld+json operationIdentifier: civil-event.lookup.verify-registration - path: generated/artifacts/civil-event--lookup-verify-registration.vocabulary.jsonld - sha256: sha256:cefa2debc49c0ce476bd1ba8b66865a15949058a14b9b3565337ea0d6b6a2773 + path: generated/artifacts/civil-event--lookup-verify-registration--representation-registrar-verification.vocabulary.jsonld + representationIdentifier: registrar-verification + sha256: sha256:fdcf02c1ff87421d65b707e8dd0de30432d2650b9c53914e55002218d4da1cb1 + visibility: operation-bound + - id: civil-event--lookup-verify-registration--representation-supervisory-capability + mediaType: application/json + operationIdentifier: civil-event.lookup.verify-registration + path: generated/artifacts/civil-event--lookup-verify-registration--representation-supervisory.capability.json + representationIdentifier: supervisory + sha256: sha256:85da4410238a0e08ad671bcef84ac1bfb0819bbc969689cb748b3eaeab5419bd visibility: operation-bound - - id: civil-event.read-capability + - id: civil-event--lookup-verify-registration--representation-supervisory-classifications + mediaType: application/json + operationIdentifier: null + path: generated/artifacts/civil-event--lookup-verify-registration--representation-supervisory.classifications.json + representationIdentifier: null + sha256: sha256:8d5a6c08fbc15972ec65e1c93d1ef2094322f081d4f4ebb30a367cedf33b9005 + visibility: operator-only + - id: civil-event--lookup-verify-registration--representation-supervisory-context + mediaType: application/ld+json + operationIdentifier: civil-event.lookup.verify-registration + path: generated/artifacts/civil-event--lookup-verify-registration--representation-supervisory.context.jsonld + representationIdentifier: supervisory + sha256: sha256:8934a9f1af201270ce58eb6fc8237e68ab60584d87643387b0a6f3da47ce1766 + visibility: operation-bound + - id: civil-event--lookup-verify-registration--representation-supervisory-processing + mediaType: application/json + operationIdentifier: civil-event.lookup.verify-registration + path: generated/artifacts/civil-event--lookup-verify-registration--representation-supervisory.processing.json + representationIdentifier: supervisory + sha256: sha256:ed8bbdd4b6111c88dd16b3bf7aaa8a126d71344ca2ebae5f735c5e2fe497917e + visibility: operation-bound + - id: civil-event--lookup-verify-registration--representation-supervisory-schema + mediaType: application/schema+json + operationIdentifier: civil-event.lookup.verify-registration + path: generated/artifacts/civil-event--lookup-verify-registration--representation-supervisory.schema.json + representationIdentifier: supervisory + sha256: sha256:49b8d47c5caffc83d776d7e3606f9bc6a86c2437048f7928b40552250582676d + visibility: operation-bound + - id: civil-event--lookup-verify-registration--representation-supervisory-shacl + mediaType: text/turtle + operationIdentifier: civil-event.lookup.verify-registration + path: generated/artifacts/civil-event--lookup-verify-registration--representation-supervisory.shacl.ttl + representationIdentifier: supervisory + sha256: sha256:1ff1315fa0f1f007d3619b6d1826f1af31ac9e11892875e53b6210a536d8eed2 + visibility: operation-bound + - id: civil-event--lookup-verify-registration--representation-supervisory-vocabulary + mediaType: application/ld+json + operationIdentifier: civil-event.lookup.verify-registration + path: generated/artifacts/civil-event--lookup-verify-registration--representation-supervisory.vocabulary.jsonld + representationIdentifier: supervisory + sha256: sha256:be70442b29f25d3cd4455bbc4385512a5990dd787ee48bc4c08f34b04a8913ef + visibility: operation-bound + - id: civil-event--read--representation-registrar-capability mediaType: application/json operationIdentifier: civil-event.read - path: generated/artifacts/civil-event--read.capability.json - sha256: sha256:f2bc9d449a1ba1abc34cef22337ec7c0446f390ee3b1625bfce557e1b637c205 + path: generated/artifacts/civil-event--read--representation-registrar.capability.json + representationIdentifier: registrar + sha256: sha256:5fbb5f391438834f199fc3974daa9e41f990fc0c3210fccfbf76f2874c635e04 visibility: operation-bound - - id: civil-event--read-classifications + - id: civil-event--read--representation-registrar-classifications mediaType: application/json operationIdentifier: null - path: generated/artifacts/civil-event--read.classifications.json - sha256: sha256:99f07ad9b7b457c1c4319d0ec4a2c76d5080008c054124c0bcfa2eee8d1135cd + path: generated/artifacts/civil-event--read--representation-registrar.classifications.json + representationIdentifier: null + sha256: sha256:eb5e6ecb0227899b83bb8cf094ec066cf0e87f580bc49ed45e47208dbf5b51ac visibility: operator-only - - id: civil-event--read-context + - id: civil-event--read--representation-registrar-context mediaType: application/ld+json operationIdentifier: civil-event.read - path: generated/artifacts/civil-event--read.context.jsonld + path: generated/artifacts/civil-event--read--representation-registrar.context.jsonld + representationIdentifier: registrar sha256: sha256:35d82eab1ee218e40fe01f1981949e19b4ee38b0fde3f2f6f0e4674a75959992 visibility: operation-bound - - id: civil-event--read-processing + - id: civil-event--read--representation-registrar-processing mediaType: application/json operationIdentifier: civil-event.read - path: generated/artifacts/civil-event--read.processing.json - sha256: sha256:bb69227aa405a9df91f21188037fc4004cb40102083c1045fff7e830e5a22365 + path: generated/artifacts/civil-event--read--representation-registrar.processing.json + representationIdentifier: registrar + sha256: sha256:c799a7c5e879200de29bad0766783f403ff31fb8aee429493668c66a49998c14 visibility: operation-bound - - id: civil-event--read-schema + - id: civil-event--read--representation-registrar-schema mediaType: application/schema+json operationIdentifier: civil-event.read - path: generated/artifacts/civil-event--read.schema.json - sha256: sha256:5f6ffead0961894227d73f4e95259a73d6a7f7cd1657e579d9de787a612f1546 + path: generated/artifacts/civil-event--read--representation-registrar.schema.json + representationIdentifier: registrar + sha256: sha256:d95b063faa78322eb16624fc0d9ed8eb08025c1206d15cd589f2a81d5f357471 visibility: operation-bound - - id: civil-event--read-shacl + - id: civil-event--read--representation-registrar-shacl mediaType: text/turtle operationIdentifier: civil-event.read - path: generated/artifacts/civil-event--read.shacl.ttl + path: generated/artifacts/civil-event--read--representation-registrar.shacl.ttl + representationIdentifier: registrar sha256: sha256:90cfe36f1e3ea53b8c098555ef4aa048d050cae75b0a4bf18f4efd55d8a9b8ea visibility: operation-bound - - id: civil-event--read-vocabulary + - id: civil-event--read--representation-registrar-vocabulary mediaType: application/ld+json operationIdentifier: civil-event.read - path: generated/artifacts/civil-event--read.vocabulary.jsonld + path: generated/artifacts/civil-event--read--representation-registrar.vocabulary.jsonld + representationIdentifier: registrar sha256: sha256:6a8225b7efed28ae336c11cbeec58bc94eaf89dcd18d2a097bc76c470f33ab85 visibility: operation-bound - id: civil-event-classification mediaType: application/json operationIdentifier: null path: generated/artifacts/civil-event.classifications.json - sha256: sha256:2439d32b87ad0cbc1f5663a30a69a65761bb265d357550ccbafe6187a0e59a9e + representationIdentifier: null + sha256: sha256:89397956719cb52cadf2ae045a2c4cd86487ed4841aa3ff2ac958b0cbfc13e54 visibility: operator-only - id: civil-event-codelist-0 mediaType: application/schema+json operationIdentifier: null path: generated/artifacts/civil-event.codelist-0.schema.json + representationIdentifier: null sha256: sha256:cbd45c06b830956e657b9e930bdd9479278f42061c7333dd5694a57a5b2a0c73 visibility: operator-only - id: civil-event-codelist-1 mediaType: application/schema+json operationIdentifier: null path: generated/artifacts/civil-event.codelist-1.schema.json + representationIdentifier: null sha256: sha256:e42dfbcab45a66032d126e0f203523ae44a6bc034278f2ce222f96f1ff0a78f0 visibility: operator-only - id: civil-event-codelist-2 mediaType: application/schema+json operationIdentifier: null path: generated/artifacts/civil-event.codelist-2.schema.json + representationIdentifier: null sha256: sha256:c770e1867500e4c771718f0d412bc92be30d138fda628a85cff787f67ec9db09 visibility: operator-only - id: civil-event-codelist-3 mediaType: application/schema+json operationIdentifier: null path: generated/artifacts/civil-event.codelist-3.schema.json + representationIdentifier: null sha256: sha256:3dd13f1498de4f4b16597ae4285412ef9e4b9859da058e45168a7de2e2252655 visibility: operator-only - id: civil-event-full-schema mediaType: application/schema+json operationIdentifier: null path: generated/artifacts/civil-event.full.schema.json - sha256: sha256:33b85e681577c03b3a4d56ca2916d254fc85d05761556f14f171d9887d6464c1 + representationIdentifier: null + sha256: sha256:891f7eb10a59b8b8022d45fbceae485c5b17d769f6a4d582c1014009fd140cb3 visibility: operator-only - id: civil-event-full-shacl mediaType: text/turtle operationIdentifier: null path: generated/artifacts/civil-event.full.shacl.ttl - sha256: sha256:0714665e47e0977b3c58650b651d098c935b25bd1d9cebc3a042275da394c795 + representationIdentifier: null + sha256: sha256:904488714d833929b35a74de0017455c5ea9018af020a81367ed033f63f8b062 visibility: operator-only - id: civil-event-full-vocabulary mediaType: application/ld+json operationIdentifier: null path: generated/artifacts/civil-event.full.vocabulary.jsonld - sha256: sha256:6a8225b7efed28ae336c11cbeec58bc94eaf89dcd18d2a097bc76c470f33ab85 + representationIdentifier: null + sha256: sha256:0d5901e0b9479f0482022cb5a1b4d7af90bfb2bf4b087b56cc46d6550c2b3b86 visibility: operator-only - id: civil-event-processing-full mediaType: application/json operationIdentifier: null path: generated/artifacts/civil-event.processing.full.json + representationIdentifier: null sha256: sha256:762086646e734b6a8248a6bb62675490edc9a303559dca7e08719925656fec40 visibility: operator-only - id: openapi-full mediaType: application/yaml operationIdentifier: null path: generated/openapi.full.yaml - sha256: sha256:4f3acbfd7340cfab5c22f26778ed103feedd41e7fabd5f2eae3b6f10c269a146 + representationIdentifier: null + sha256: sha256:0192677820ef2d9ba0d0bd1548fab606badd415763195407e6a48fca79f89211 visibility: operator-only - id: openapi-public mediaType: application/json operationIdentifier: null path: generated/openapi.public.json + representationIdentifier: null sha256: sha256:2dc557335daf6824d9a037998ef20fa1efe835c877fb46feec1e3fd2d19ac392 visibility: public governedFiles: @@ -574,6 +874,18 @@ projects: sha256: sha256:7b26678d41f6705d3bb83d274b234834f3c98f5265a93151417fe91d499d2cf4 size: 104 visibility: operator-only + - generated: false + mediaType: application/yaml + path: governed/governance/classification-review-rationale.md + sha256: sha256:23a1ae8431bf2979f60a60191407a832b2d4fbae035bc3ca471465590b6243be + size: 185 + visibility: operator-only + - generated: false + mediaType: application/yaml + path: governed/governance/classification-review.yaml + sha256: sha256:7de2425d26c6b841a44fa4651eb336b07eb74d5d564d502e9298536416904558 + size: 423 + visibility: operator-only - generated: false mediaType: application/yaml path: governed/governance/identifier-lifecycle.yaml @@ -595,6 +907,6 @@ projects: - generated: false mediaType: application/yaml path: registry.yaml - sha256: sha256:123d00a7872f1655e7cfae58ec41367b8c8a8882f36dac12c29e62fc31007ef1 - size: 6915 + sha256: sha256:59916fb89188b9eda0fddde75f96dee7f25027b694849c648f94acd023a424e1 + size: 8151 visibility: operator-only diff --git a/products/relay-v2/contracts/package-layout.yaml b/products/relay-v2/contracts/package-layout.yaml index 24d935e82..da02f53c9 100644 --- a/products/relay-v2/contracts/package-layout.yaml +++ b/products/relay-v2/contracts/package-layout.yaml @@ -27,6 +27,7 @@ projectFiles: - runtime.yaml - fixture.sql - expected-http.yaml + - governance/classification-review.yaml generatedFilesCommitted: false semanticHashSnapshotsCommitted: true generatedFilePolicy: Generated SQLite databases and runtime artifacts are built in temporary directories and are not tracked; reviewed semantic hashes and exposure metadata are committed. diff --git a/products/relay-v2/contracts/security-invariant-matrix.yaml b/products/relay-v2/contracts/security-invariant-matrix.yaml index 1bec16dfa..3c60956bb 100644 --- a/products/relay-v2/contracts/security-invariant-matrix.yaml +++ b/products/relay-v2/contracts/security-invariant-matrix.yaml @@ -46,6 +46,57 @@ invariants: tests: - {path: crates/registry-relay-v2/tests/acceptance_http.rs, name: all_three_registry_http_journeys_use_the_real_router} - {path: crates/registry-relay-v2/tests/multi_resource_isolation.rs, name: real_router_keeps_related_public_and_protected_resources_isolated} + - id: sec-classification-review-binding + threat: A generated, stale, tampered, uncertain, or mismatched classification review silently authorizes a changed disclosure contract. + enforcementPoint: Governed-file review parsing and compilation against the canonical classification inventory digest. + negativeCase: stale_or_unreviewed_classification_review_is_refused + expected: Production compilation accepts only reviewed inventory-bound evidence; generated review additionally binds the accepted report and exact rule pack, while manual and imported review do not require a report. + evidence: review-binding-compiler-tests + tests: + - {path: crates/registry-relay-v2/src/compiler.rs, name: stale_review_fails_production_but_remains_an_authoring_finding} + - {path: crates/registry-relay-v2/src/compiler.rs, name: starter_never_marks_classification_reviewed} + - id: sec-finite-representation-authorization + threat: A request selects an undeclared, malformed, or denied representation, crosses profiles with fields, or falls back to a different disclosure. + enforcementPoint: Closed compiled representation map, one exact default, pre-source selection, and selected-profile field validation. + negativeCase: representation_selection_or_cross_profile_fields_fall_back_or_reach_source + expected: An operation has one declared default and finite names; access and disclosure are evaluated only for the exact selection, and fields cannot cross the selected profile. + evidence: compiler-and-real-router-representation-tests + tests: + - {path: crates/registry-relay-v2/src/compiler.rs, name: representation_default_and_transform_parameters_fail_closed} + - {path: crates/registry-relay-v2/tests/representation_http.rs, name: representation_selection_authenticates_then_authorizes_the_exact_profile} + - {path: crates/registry-relay-v2/tests/representation_http.rs, name: preflight_refusals_do_not_reach_source_and_attempt_audit_precedes_source_access} + - {path: crates/registry-relay-v2/tests/representation_http.rs, name: fields_only_minimize_the_selected_representation} + - id: sec-public-representation-processing-floor + threat: A public masked or minimized representation reads a confidential or restricted raw source column. + enforcementPoint: Per-representation processed-column closure and processing-handling compilation before route activation. + negativeCase: public_representation_processes_nonpublic_raw_source + expected: Anonymous release requires public processing handling, including transform inputs and every selector, filter, order, and Registry Core source column. + evidence: compiler-handling-tests + tests: + - {path: crates/registry-relay-v2/src/compiler.rs, name: public_masked_representation_cannot_process_restricted_source} + - {path: crates/registry-relay-v2/src/compiler.rs, name: public_operation_cannot_process_nonpublic_columns} + - id: sec-closed-mask-and-date-transforms + threat: Caller-directed or malformed transform input releases a raw, complete, noncanonical, wrongly typed, or over-precise value. + enforcementPoint: Compiled transform catalog and value-free source failure before response serialization. + negativeCase: transform_reveals_or_serializes_incompatible_input + expected: Only bounded partial-string with the Relay-owned marker and typed date-precision execute; short strings never reveal complete input and incompatible date input fails closed. + evidence: transform-unit-and-compiler-tests + tests: + - {path: crates/registry-relay-v2/src/transform.rs, name: partial_string_never_reveals_a_complete_short_input} + - {path: crates/registry-relay-v2/src/transform.rs, name: partial_string_counts_unicode_scalars} + - {path: crates/registry-relay-v2/src/transform.rs, name: date_precision_accepts_only_the_compiled_source_shape} + - {path: crates/registry-relay-v2/src/compiler.rs, name: date_precision_is_typed_and_closed} + - {path: crates/registry-relay-v2/tests/representation_http.rs, name: transforms_are_bounded_value_free_and_terminal_audit_gates_exact_bytes} + - id: sec-representation-state-and-metadata-binding + threat: A cursor, ETag, metadata route, artifact, or quota crosses a representation boundary or reveals a protected profile. + enforcementPoint: Representation-bound cursor and cache identity, exact representation artifact gates, and operation-owned quota state. + negativeCase: profile_state_or_metadata_crosses_representation_boundary + expected: Cursor and ETag reuse across profiles fails; metadata and artifacts authorize one representation exactly; adding profiles does not multiply the operation quota. + evidence: real-router-representation-state-tests + tests: + - {path: crates/registry-relay-v2/tests/representation_http.rs, name: cursor_and_etag_are_bound_to_selected_representation} + - {path: crates/registry-relay-v2/tests/representation_http.rs, name: metadata_and_artifacts_authorize_each_representation_exactly} + - {path: crates/registry-relay-v2/tests/representation_http.rs, name: quotas_remain_operation_scoped_across_representations} - id: sec-operation-quota threat: One named lookup exhausts another resource or operation's request budget. enforcementPoint: Runtime quota configuration compiled into an operation-keyed limiter before readiness. diff --git a/products/relay-v2/scripts/check-contracts.sh b/products/relay-v2/scripts/check-contracts.sh index 32d44ef15..8d92abd07 100755 --- a/products/relay-v2/scripts/check-contracts.sh +++ b/products/relay-v2/scripts/check-contracts.sh @@ -6,7 +6,9 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" python3 "$SCRIPT_DIR/validate_product.py" bash "$SCRIPT_DIR/check-source-neutrality.sh" -python3 -m unittest "$SCRIPT_DIR/test_validate_product.py" +python3 -m unittest \ + "$SCRIPT_DIR/test_validate_product.py" \ + "$SCRIPT_DIR/test_adopter_workflow_openapi.py" bash "$SCRIPT_DIR/check-configs.sh" echo "relay-v2 product contracts passed" diff --git a/products/relay-v2/scripts/test_adopter_workflow.py b/products/relay-v2/scripts/test_adopter_workflow.py index 1604048d1..930fac523 100755 --- a/products/relay-v2/scripts/test_adopter_workflow.py +++ b/products/relay-v2/scripts/test_adopter_workflow.py @@ -117,16 +117,101 @@ def openapi_operations(document: dict[str, Any]) -> dict[tuple[str, str], dict[s return result -def validate_openapi(package: Path) -> None: +def representation_identifiers(operation: dict[str, Any], label: str) -> set[str]: + profiles = operation.get("x-registry-representations") + if not isinstance(profiles, list) or not profiles: + raise GateFailure(f"{label} has no finite representations") + identifiers: set[str] = set() + for profile in profiles: + if not isinstance(profile, dict) or not isinstance(profile.get("identifier"), str): + raise GateFailure(f"{label} has a malformed representation") + identifier = profile["identifier"] + if not identifier or identifier in identifiers: + raise GateFailure(f"{label} has duplicate or empty representation identifiers") + identifiers.add(identifier) + return identifiers + + +def public_representation_parameters(operation: dict[str, Any], label: str) -> set[str]: + parameters = operation.get("parameters") + if not isinstance(parameters, list): + raise GateFailure(f"{label} has no parameters") + matches = [ + parameter + for parameter in parameters + if isinstance(parameter, dict) + and parameter.get("name") == "representation" + and parameter.get("in") == "query" + ] + if len(matches) != 1: + raise GateFailure(f"{label} has no unique representation parameter") + identifiers = matches[0].get("schema", {}).get("enum") + if not isinstance(identifiers, list) or not all(isinstance(item, str) for item in identifiers): + raise GateFailure(f"{label} has a malformed representation parameter") + return set(identifiers) + + +def artifact_identifier(reference: Any) -> str | None: + if not isinstance(reference, str) or not reference: + return None + return reference.rsplit("/", 1)[-1] + + +def validate_public_operation( + public: dict[str, Any], full: dict[str, Any], public_artifact_ids: set[str] +) -> None: + if public.get("operationId") != full.get("operationId"): + raise GateFailure("public OpenAPI operation identifier does not match full OpenAPI") + public_ids = representation_identifiers(public, "public OpenAPI operation") + full_ids = representation_identifiers(full, "full OpenAPI operation") + if not public_ids.issubset(full_ids): + raise GateFailure("public OpenAPI representation is absent from full OpenAPI") + if public_representation_parameters(public, "public OpenAPI operation") != public_ids: + raise GateFailure("public OpenAPI representation parameter does not match public profiles") + if public.get("security") != [] or "x-registry-required-scopes" in public: + raise GateFailure("public OpenAPI operation carries protected access or security") + full_profiles = { + profile["identifier"]: profile + for profile in full["x-registry-representations"] + } + protected_ids = { + entry.get("representation") + for entry in full.get("x-registry-required-scopes", []) + if isinstance(entry, dict) and isinstance(entry.get("representation"), str) + } + for profile in public["x-registry-representations"]: + identifier = profile["identifier"] + if identifier in protected_ids: + raise GateFailure("public OpenAPI exposes a protected representation") + if profile != full_profiles[identifier]: + raise GateFailure("public OpenAPI representation differs from its full profile") + for reference_key in ( + "schemaReference", + "semanticModelReference", + "contextReference", + ): + if artifact_identifier(profile.get(reference_key)) not in public_artifact_ids: + raise GateFailure("public OpenAPI references an artifact absent from public output") + + +def validate_openapi(package: Path, artifacts: list[dict[str, Any]]) -> None: full = yaml.safe_load((package / "generated/openapi.full.yaml").read_text(encoding="utf-8")) public = json.loads((package / "generated/openapi.public.json").read_text(encoding="utf-8")) full_operations = openapi_operations(full) public_operations = openapi_operations(public) + public_artifact_ids = { + artifact["id"] + for artifact in artifacts + if artifact.get("visibility") == "public" and isinstance(artifact.get("id"), str) + } for key, operation in public_operations.items(): - if full_operations.get(key) != operation: - raise GateFailure("public OpenAPI is not an exact path subset of full OpenAPI") - if operation.get("security") == [{"bearerAuth": []}]: - raise GateFailure("public OpenAPI exposes a protected-only operation") + full_operation = full_operations.get(key) + if full_operation is None: + raise GateFailure("public OpenAPI path is absent from full OpenAPI") + if "x-registry-representations" in operation or "x-registry-representations" in full_operation: + validate_public_operation(operation, full_operation, public_artifact_ids) + elif operation != full_operation: + raise GateFailure("public fixed OpenAPI operation differs from full OpenAPI") capabilities = json.loads( (package / "generated/artifacts/capabilities.full.json").read_text(encoding="utf-8") @@ -210,7 +295,7 @@ def validate_exposure_and_identity(package: Path, generated: Path) -> dict[str, raise GateFailure("full OpenAPI is not package-only") if by_id.get("openapi-public", {}).get("visibility") != "public": raise GateFailure("public OpenAPI is not explicitly public") - validate_openapi(package) + validate_openapi(package, artifacts) return manifest diff --git a/products/relay-v2/scripts/test_adopter_workflow_openapi.py b/products/relay-v2/scripts/test_adopter_workflow_openapi.py new file mode 100644 index 000000000..7fbef3db9 --- /dev/null +++ b/products/relay-v2/scripts/test_adopter_workflow_openapi.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import copy +import importlib.util +import sys +import unittest +from pathlib import Path + + +SCRIPT_PATH = Path(__file__).with_name("test_adopter_workflow.py") +SPEC = importlib.util.spec_from_file_location("relay_v2_adopter_workflow", SCRIPT_PATH) +assert SPEC is not None and SPEC.loader is not None +WORKFLOW = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = WORKFLOW +SPEC.loader.exec_module(WORKFLOW) + + +class PublicOpenApiProjectionTests(unittest.TestCase): + def test_rejects_a_protected_representation_in_public_output(self) -> None: + public_profile = { + "identifier": "public-register", + "default": True, + "disclosureProfile": "public-register", + "processingHandling": "public", + "disclosureHandling": "public", + "transformIdentifiers": [], + "schemaReference": "https://registry.example.invalid/v2/artifacts/public-schema", + "semanticModelReference": "https://registry.example.invalid/v2/artifacts/public-vocabulary", + "contextReference": "https://registry.example.invalid/v2/artifacts/public-context", + } + protected_profile = { + **public_profile, + "identifier": "registrar", + "default": False, + "disclosureProfile": "registrar", + "processingHandling": "confidential", + "disclosureHandling": "confidential", + "schemaReference": "https://registry.example.invalid/v2/artifacts/registrar-schema", + "semanticModelReference": "https://registry.example.invalid/v2/artifacts/registrar-vocabulary", + "contextReference": "https://registry.example.invalid/v2/artifacts/registrar-context", + } + full = { + "operationId": "business.read", + "security": [{}, {"bearerAuth": []}], + "x-registry-representations": [public_profile, protected_profile], + "x-registry-required-scopes": [ + {"representation": "registrar", "scope": "registry:business:read-registrar"} + ], + } + public = { + "operationId": "business.read", + "security": [], + "parameters": [ + { + "name": "representation", + "in": "query", + "schema": {"enum": ["public-register", "registrar"]}, + } + ], + "x-registry-representations": [public_profile, copy.deepcopy(protected_profile)], + } + with self.assertRaisesRegex(WORKFLOW.GateFailure, "protected representation"): + WORKFLOW.validate_public_operation( + public, + full, + {"public-schema", "public-vocabulary", "public-context"}, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/products/relay-v2/scripts/test_validate_product.py b/products/relay-v2/scripts/test_validate_product.py index 622546242..79ffb6cd7 100644 --- a/products/relay-v2/scripts/test_validate_product.py +++ b/products/relay-v2/scripts/test_validate_product.py @@ -86,6 +86,75 @@ def load_without_excessive_size(path: Path): VALIDATOR.validate_catalogs(errors) self.assertTrue(any("invalid source-row classes must cover" in error for error in errors), errors) + def test_each_operation_requires_a_declared_default_representation(self) -> None: + original = VALIDATOR.load_yaml + + def load_without_social_lookup_default(path: Path): + value = copy.deepcopy(original(path)) + if path.name == "registry.yaml" and path.parent.name == "social-assistance": + value["resources"][0]["operations"]["lookups"][0].pop( + "defaultRepresentation" + ) + return value + + errors: list[str] = [] + with mock.patch.object(VALIDATOR, "load_yaml", side_effect=load_without_social_lookup_default): + VALIDATOR.validate_acceptance_representation_contracts(errors) + self.assertTrue( + any("every declared operation needs one declared default representation" in error for error in errors), + errors, + ) + + def test_generated_review_must_keep_its_required_method(self) -> None: + original = VALIDATOR.load_yaml + + def load_with_manual_social_review(path: Path): + value = copy.deepcopy(original(path)) + if path.name == "classification-review.yaml" and path.parent.name == "governance": + if path.parents[1].name == "social-assistance": + value["method"] = "manual" + value.pop("generatedIdentification") + return value + + errors: list[str] = [] + with mock.patch.object(VALIDATOR, "load_yaml", side_effect=load_with_manual_social_review): + VALIDATOR.validate_acceptance_representation_contracts(errors) + self.assertTrue( + any("does not use the required reviewed method" in error for error in errors), errors + ) + + def test_social_quota_fixture_is_bound_to_the_pre_quota_journey(self) -> None: + original = VALIDATOR.load_yaml + + def load_with_social_quota_drift(path: Path): + value = copy.deepcopy(original(path)) + if path.name == "runtime.yaml" and path.parent.name == "social-assistance": + value["quotas"]["burst"] = 11 + return value + + errors: list[str] = [] + with mock.patch.object(VALIDATOR, "load_yaml", side_effect=load_with_social_quota_drift): + VALIDATOR.validate_acceptance_representation_contracts(errors) + self.assertTrue( + any("quota fixture must admit exactly" in error for error in errors), errors + ) + + def test_civil_lookup_quota_fixture_is_bound_to_the_pre_quota_journey(self) -> None: + original = VALIDATOR.load_yaml + + def load_with_civil_quota_drift(path: Path): + value = copy.deepcopy(original(path)) + if path.name == "runtime.yaml" and path.parent.name == "civil-event": + value["quotas"]["burst"] = 7 + return value + + errors: list[str] = [] + with mock.patch.object(VALIDATOR, "load_yaml", side_effect=load_with_civil_quota_drift): + VALIDATOR.validate_acceptance_representation_contracts(errors) + self.assertTrue( + any("lookup quota fixture must admit exactly" in error for error in errors), errors + ) + def test_security_test_resolution_rejects_a_similar_prefix(self) -> None: errors: list[str] = [] VALIDATOR.executable_test_resolves( diff --git a/products/relay-v2/scripts/validate_product.py b/products/relay-v2/scripts/validate_product.py index 6a22058d7..09939ee7a 100644 --- a/products/relay-v2/scripts/validate_product.py +++ b/products/relay-v2/scripts/validate_product.py @@ -30,6 +30,7 @@ "excessive-size", } SIMPLE_IDENTIFIER = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") +SHA256 = re.compile(r"^sha256:[0-9a-f]{64}$") RUST_TEST = re.compile( r"#\[(?:tokio::)?test(?:\([^\]]*\))?\]" r"(?:\s*#\[[^\]]+\])*\s*(?:async\s+)?fn\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(", @@ -96,7 +97,13 @@ def journey_steps(errors: list[str]) -> dict[str, set[str]]: result: dict[str, set[str]] = {} for project_name in PROJECTS: project = PRODUCT_ROOT / "acceptance" / project_name - for required in ("registry.yaml", "runtime.yaml", "fixture.sql", "expected-http.yaml"): + for required in ( + "registry.yaml", + "runtime.yaml", + "fixture.sql", + "expected-http.yaml", + "governance/classification-review.yaml", + ): if not (project / required).is_file(): errors.append(f"{project_name}: missing required project file {required}") journey = mapping(load_yaml(project / "expected-http.yaml"), f"{project_name} journey", errors) @@ -122,7 +129,142 @@ def journey_steps(errors: list[str]) -> dict[str, set[str]]: return result +def validate_review_sidecar( + project: Path, registry: dict[str, Any], expected_method: str, errors: list[str] +) -> None: + classifications = mapping(registry.get("classifications"), f"{project.name} classifications", errors) + reference = classifications.get("provenanceRef") + if not isinstance(reference, str) or not reference: + errors.append(f"{project.name}: classifications.provenanceRef must name the review sidecar") + return + sidecar = project / reference + if not sidecar.is_file(): + errors.append(f"{project.name}: classification review sidecar is missing") + return + review = mapping(load_yaml(sidecar), f"{project.name} classification review", errors) + require_exact_keys( + review, + { + "apiVersion", + "kind", + "registryIdentifier", + "classificationInventoryDigest", + "method", + "reviewer", + "reviewDate", + "status", + "rationaleRef", + } + | ({"generatedIdentification"} if expected_method == "generated" else set()), + f"{project.name} classification review", + errors, + ) + if review.get("apiVersion") != "relay.registrystack.org/classification-review/v1": + errors.append(f"{project.name}: classification review apiVersion is not frozen") + if review.get("kind") != "ClassificationReview": + errors.append(f"{project.name}: classification review kind is not frozen") + if review.get("registryIdentifier") != registry.get("registry", {}).get("registryIdentifier"): + errors.append(f"{project.name}: classification review binds another Registry") + if review.get("method") != expected_method or review.get("status") != "reviewed": + errors.append(f"{project.name}: classification review does not use the required reviewed method") + if not SHA256.fullmatch(str(review.get("classificationInventoryDigest", ""))): + errors.append(f"{project.name}: classification review inventory digest is invalid") + generated = review.get("generatedIdentification") + if expected_method == "generated": + generated_binding = mapping(generated, f"{project.name} generated review binding", errors) + require_exact_keys( + generated_binding, + {"reportRef", "reportDigest", "rulePack"}, + f"{project.name} generated review binding", + errors, + ) + report_ref = generated_binding.get("reportRef") + if report_ref != "reports/identification-report.json" or not (project / str(report_ref)).is_file(): + errors.append(f"{project.name}: generated review must bind the accepted identification report") + if not SHA256.fullmatch(str(generated_binding.get("reportDigest", ""))): + errors.append(f"{project.name}: generated review report digest is invalid") + rule_pack = mapping(generated_binding.get("rulePack"), f"{project.name} rule pack", errors) + require_exact_keys(rule_pack, {"id", "version", "digest"}, f"{project.name} rule pack", errors) + if not SHA256.fullmatch(str(rule_pack.get("digest", ""))): + errors.append(f"{project.name}: generated review rule-pack digest is invalid") + elif generated is not None: + errors.append(f"{project.name}: imported or manual review must not carry generated binding") + + +def validate_acceptance_representation_contracts(errors: list[str]) -> None: + expected_methods = { + "social-assistance": "generated", + "business-registry": "imported", + "civil-event": "manual", + } + expected_representations = { + "social-assistance": {"limited", "caseworker"}, + "business-registry": {"public-register", "registrar"}, + "civil-event": {"registrar", "supervisory"}, + } + for project_name in PROJECTS: + project = PRODUCT_ROOT / "acceptance" / project_name + registry = mapping(load_yaml(project / "registry.yaml"), f"{project_name} registry", errors) + validate_review_sidecar(project, registry, expected_methods[project_name], errors) + resources = sequence(registry.get("resources"), f"{project_name} resources", errors) + operations = mapping(resources[0].get("operations") if resources else None, f"{project_name} operations", errors) + representations: set[str] = set() + operation_definitions = [operations.get("list"), operations.get("read")] + list( + operations.get("lookups", []) if isinstance(operations.get("lookups"), list) else [] + ) + for index, operation in enumerate(operation_definitions): + if operation is None: + continue + operation = mapping(operation, f"{project_name} operation[{index}]", errors) + profiles = mapping(operation.get("representations"), f"{project_name} operation[{index}] representations", errors) + default = operation.get("defaultRepresentation") + if not isinstance(default, str) or default not in profiles or not profiles: + errors.append(f"{project_name}: every declared operation needs one declared default representation") + for identifier, representation in profiles.items(): + representations.add(identifier) + representation = mapping(representation, f"{project_name} representation {identifier}", errors) + require_exact_keys( + representation, + {"access", "disclosureProfile"}, + f"{project_name} representation {identifier}", + errors, + ) + if not expected_representations[project_name].issubset(representations): + errors.append(f"{project_name}: required acceptance representations are missing") + if project_name == "social-assistance": + properties = resources[0].get("properties", {}) if resources else {} + transform = mapping(properties.get("maskedEnrolmentReference", {}).get("transform"), "social partial-string transform", errors) + if transform != {"kind": "partial-string", "reveal": "suffix", "characters": 4}: + errors.append("social-assistance: limited representation must use the frozen partial-string transform") + runtime = mapping( + load_yaml(project / "runtime.yaml"), "social-assistance runtime", errors + ) + quotas = mapping(runtime.get("quotas"), "social-assistance quotas", errors) + if quotas != {"requestsPerMinute": 1, "burst": 12}: + errors.append( + "social-assistance: quota fixture must admit exactly the twelve " + "pre-quota lookup executions before the named rate-limit proof" + ) + if project_name == "civil-event": + properties = resources[0].get("properties", {}) if resources else {} + transform = mapping(properties.get("registrationYear", {}).get("transform"), "civil date-precision transform", errors) + if transform != {"kind": "date-precision", "sourceType": "date", "precision": "year"}: + errors.append("civil-event: supervisory representation must use the frozen date-precision transform") + if operations.get("list") is not None: + errors.append("civil-event: collection list remains out of scope") + runtime = mapping( + load_yaml(project / "runtime.yaml"), "civil-event runtime", errors + ) + quotas = mapping(runtime.get("quotas"), "civil-event quotas", errors) + if quotas != {"requestsPerMinute": 1, "burst": 8}: + errors.append( + "civil-event: lookup quota fixture must admit exactly the eight " + "pre-quota lookup executions before the named rate-limit proof" + ) + + def validate_catalogs(errors: list[str]) -> None: + validate_acceptance_representation_contracts(errors) layout = mapping( load_yaml(PRODUCT_ROOT / "contracts/package-layout.yaml"), "package layout", errors ) @@ -193,6 +335,7 @@ def validate_catalogs(errors: list[str]) -> None: "openapi-full", "openapi-public", "representation-schema", + "representation-shacl", "full-record-schema", "full-record-shacl", "semantic-model", @@ -201,6 +344,11 @@ def validate_catalogs(errors: list[str]) -> None: "codelists", "capability-inventory", "audit-event-schema", + "identification-report", + "classification-inventory", + "representation-report", + "contextual-review-findings", + "classification-review", }: if required not in artifact_ids: errors.append(f"artifact inventory: missing {required}") From 76630232635d4af6dfd1b06406468b12f216c668 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 10 Aug 2026 13:23:27 +0700 Subject: [PATCH 05/24] fix(platform): restore SQLite statement isolation Signed-off-by: Jeremi Joslin --- crates/registry-evidence/src/source_sqlite.rs | 23 +- .../registry-platform-sqlite/src/statement.rs | 381 +++++++++++++++--- .../registry-platform-sqlite/tests/kernel.rs | 22 +- 3 files changed, 356 insertions(+), 70 deletions(-) diff --git a/crates/registry-evidence/src/source_sqlite.rs b/crates/registry-evidence/src/source_sqlite.rs index 5c94c99db..23da907ed 100644 --- a/crates/registry-evidence/src/source_sqlite.rs +++ b/crates/registry-evidence/src/source_sqlite.rs @@ -323,9 +323,11 @@ impl SqliteExtractSource { request.maximum_statement_steps, timeout, )?; - let statement = - ReadOnlyStatement::open(profile, platform_contract(request, statement_sql)?) - .map_err(|error| map_platform_error(error, artifact, extract_profile))?; + let statement = ReadOnlyStatement::open_with_text_value_response_budget( + profile, + platform_contract(request, statement_sql)?, + ) + .map_err(|error| map_platform_error(error, artifact, extract_profile))?; let parameters = request .parameter_bindings .keys() @@ -376,9 +378,10 @@ impl SqliteExtractSource { /// The result is `{"rows": [...], "extract": {...}}`. Applying the /// acquisition projection belongs to the caller, which does it for every /// transport, and so does the authoritative response size check. Collection - /// here conservatively charges the serialized collection, row, column-name, - /// and scalar-value structure against the same bound so the intermediate - /// result is bounded before the caller projects it. + /// here charges the original UTF-8 text payload against that same bound so + /// the intermediate result is bounded before the caller projects it. This + /// is the frozen Evidence accounting contract: the caller's later check is + /// authoritative for the complete serialized result. /// /// A caller may stop awaiting this at any point: the acquisition deadline /// above it expires, or a client disconnects and the handler future is @@ -1297,13 +1300,13 @@ factSchema: schemas/facts.schema.yaml async fn a_result_beyond_the_response_bound_is_refused_as_it_is_collected() { let directory = TempDir::new().expect("a temporary directory"); let path = extract(&directory); - let plan = Plan::default().response_bytes(39); + let plan = Plan::default().response_bytes(8); let source = open(&plan, "SELECT id FROM person ORDER BY id", &path); assert_eq!(run_error(&source).await, cause::RESPONSE_TOO_LARGE); - // The exact compact JSON collection is 40 bytes: three one-property - // objects containing the three identifiers, plus delimiters and keys. - let exact = Plan::default().response_bytes(40); + // The three identifiers are nine bytes of text between them, and the + // count is of text alone, so a bound of nine admits exactly them. + let exact = Plan::default().response_bytes(9); let source = open(&exact, "SELECT id FROM person ORDER BY id", &path); let result = run(&source, "the response bound admits its own size").await; assert_eq!( diff --git a/crates/registry-platform-sqlite/src/statement.rs b/crates/registry-platform-sqlite/src/statement.rs index 0adaf8de4..6c3dca808 100644 --- a/crates/registry-platform-sqlite/src/statement.rs +++ b/crates/registry-platform-sqlite/src/statement.rs @@ -78,6 +78,12 @@ pub struct StatementLimits { pub concurrency: usize, } +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +enum ResponseBudgetAccounting { + SerializedResult, + TextValuesOnly, +} + impl StatementLimits { fn validate(&self) -> Result<(), SqliteError> { if self.maximum_rows == 0 @@ -221,6 +227,12 @@ struct CompiledPlan { limits: StatementLimits, schema: Option, statement_digest: String, + response_budget_accounting: ResponseBudgetAccounting, +} + +struct ConnectionExecution { + outcome: Result, + reusable: bool, } /// One compiled statement and one read-only connection pool. @@ -235,6 +247,36 @@ impl ReadOnlyStatement { pub fn open( profile: DatabaseProfile, contract: StatementContract, + ) -> Result { + Self::open_with_response_budget_accounting( + profile, + contract, + ResponseBudgetAccounting::SerializedResult, + ) + } + + /// Open a statement while charging only the original UTF-8 bytes of text + /// values to the intermediate response budget. + /// + /// This preserves callers whose established contract applies the + /// authoritative serialized-response bound after projection. New consumers + /// should use [`Self::open`], which charges the compact JSON result + /// structure as it is collected. + pub fn open_with_text_value_response_budget( + profile: DatabaseProfile, + contract: StatementContract, + ) -> Result { + Self::open_with_response_budget_accounting( + profile, + contract, + ResponseBudgetAccounting::TextValuesOnly, + ) + } + + fn open_with_response_budget_accounting( + profile: DatabaseProfile, + contract: StatementContract, + response_budget_accounting: ResponseBudgetAccounting, ) -> Result { contract.limits.validate()?; if let Some(schema) = &contract.schema { @@ -266,6 +308,7 @@ impl ReadOnlyStatement { limits: contract.limits, schema: contract.schema, statement_digest, + response_budget_accounting, }), connections: Arc::new(Mutex::new(connections)), concurrency: Arc::new(Semaphore::new(permits)), @@ -296,15 +339,17 @@ impl ReadOnlyStatement { .ok_or_else(|| SqliteError::new(ErrorKind::WorkerUnavailable))?; let plan = Arc::clone(&self.plan); let pool = Arc::clone(&self.connections); + let profile = self.profile.clone(); let execution = tokio::task::spawn_blocking(move || { - let result = confirm_connection_still_bound(&connection) - .and_then(|()| run_statement(&connection, &plan, &bindings, deadline)) - .and_then(|result| confirm_connection_still_bound(&connection).map(|()| result)); - pool.lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .push(connection); - drop(permit); - result + let execution = execute_on_connection(&connection, &plan, &bindings, deadline); + if return_or_replace_connection(&pool, &profile, connection, execution.reusable) { + drop(permit); + } else { + // No connection backs this slot, so permanently remove the + // permit rather than admit a request to an empty pool. + permit.forget(); + } + execution.outcome }); let (rows, schema_fingerprint) = tokio::time::timeout_at(async_deadline, execution) .await @@ -331,15 +376,18 @@ impl ReadOnlyStatement { .unwrap_or_else(std::sync::PoisonError::into_inner) .pop() .ok_or_else(|| SqliteError::new(ErrorKind::WorkerUnavailable))?; - let outcome = confirm_connection_still_bound(&connection) - .and_then(|()| run_statement(&connection, &self.plan, &bindings, deadline)) - .and_then(|result| confirm_connection_still_bound(&connection).map(|()| result)); - self.connections - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .push(connection); + let execution = execute_on_connection(&connection, &self.plan, &bindings, deadline); + let restored = return_or_replace_connection( + &self.connections, + &self.profile, + connection, + execution.reusable, + ); + if !restored { + self.concurrency.forget_permits(1); + } self.profile.confirm()?; - let (rows, schema_fingerprint) = outcome?; + let (rows, schema_fingerprint) = execution.outcome?; Ok(ResultSet { rows, provenance: self.provenance(schema_fingerprint), @@ -437,6 +485,61 @@ fn confirm_connection_pool_still_bound(connections: &[Connection]) -> Result<(), Ok(()) } +fn execute_on_connection( + connection: &Connection, + plan: &CompiledPlan, + bindings: &[(usize, Value)], + deadline: Instant, +) -> ConnectionExecution<(Vec, Option)> { + if let Err(error) = confirm_connection_still_bound(connection) { + return ConnectionExecution { + outcome: Err(error), + reusable: false, + }; + } + let mut execution = run_statement(connection, plan, bindings, deadline); + if execution.reusable { + if let Err(error) = confirm_connection_still_bound(connection) { + if execution.outcome.is_ok() { + execution.outcome = Err(error); + } + execution.reusable = false; + } + } + execution +} + +fn return_or_replace_connection( + connections: &Mutex>, + profile: &DatabaseProfile, + connection: Connection, + reusable: bool, +) -> bool { + let connection = if reusable { + Some(connection) + } else { + drop(connection); + open_replacement_connection(profile).ok() + }; + if let Some(connection) = connection { + connections + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push(connection); + true + } else { + false + } +} + +fn open_replacement_connection(profile: &DatabaseProfile) -> Result { + profile.confirm()?; + let connection = open_connection(profile)?; + profile.confirm()?; + confirm_connection_still_bound(&connection)?; + Ok(connection) +} + /// Ask the active SQLite VFS whether the actual `main` handle has moved away /// from the pathname that opened it. This detects a swap-and-restore attack /// that pathname metadata checks alone cannot see. @@ -707,14 +810,18 @@ fn run_statement( plan: &CompiledPlan, bindings: &[(usize, Value)], deadline: Instant, -) -> Result<(Vec, Option), SqliteError> { - begin_read_transaction(connection)?; - let outcome = run_statement_in_transaction(connection, plan, bindings, deadline); - let closed = end_read_transaction(connection); - match (outcome, closed) { +) -> ConnectionExecution<(Vec, Option)> { + let outcome = begin_read_transaction(connection) + .and_then(|()| run_statement_in_transaction(connection, plan, bindings, deadline)); + let cleaned = reset_connection_after_read(connection); + let outcome = match (outcome, &cleaned) { (Ok(value), Ok(())) => Ok(value), (Err(error), _) => Err(error), - (Ok(_), Err(error)) => Err(error), + (Ok(_), Err(error)) => Err(error.clone()), + }; + ConnectionExecution { + outcome, + reusable: cleaned.is_ok(), } } @@ -745,10 +852,12 @@ fn run_statement_in_transaction( } let mut rows = statement.raw_query(); let mut collected = Vec::new(); - // Include the outer collection even when it is empty. This is a - // conservative serialization/allocation budget, not just cell payload. let mut response_bytes = 0_usize; - charge_response(&mut response_bytes, 2, plan.limits.maximum_response_bytes)?; + if plan.response_budget_accounting == ResponseBudgetAccounting::SerializedResult { + // Include the outer collection even when it is empty. This is a + // conservative serialization/allocation budget, not just cell payload. + charge_response(&mut response_bytes, 2, plan.limits.maximum_response_bytes)?; + } loop { let row = match rows.next() { Ok(Some(row)) => row, @@ -758,11 +867,13 @@ fn run_statement_in_transaction( if collected.len() as u64 >= plan.limits.maximum_rows { return Err(SqliteError::new(ErrorKind::TooManyRows)); } - charge_response( - &mut response_bytes, - if collected.is_empty() { 2 } else { 3 }, - plan.limits.maximum_response_bytes, - )?; + if plan.response_budget_accounting == ResponseBudgetAccounting::SerializedResult { + charge_response( + &mut response_bytes, + if collected.is_empty() { 2 } else { 3 }, + plan.limits.maximum_response_bytes, + )?; + } collected.push(read_row(row, plan, &mut response_bytes)?); } Ok((collected, schema_fingerprint)) @@ -780,16 +891,25 @@ fn begin_read_transaction(connection: &Connection) -> Result<(), SqliteError> { Ok(()) } -fn end_read_transaction(connection: &Connection) -> Result<(), SqliteError> { - connection +fn reset_connection_after_read(connection: &Connection) -> Result<(), SqliteError> { + // Every reset is attempted even when an earlier one fails. In particular, + // an authorizer-installation failure must not skip rollback, and a rollback + // failure must not leave the connection without the reviewed authorizer. + let mut reusable = connection.progress_handler(0, None:: bool>).is_ok(); + reusable &= connection .authorizer(None::) -> Authorization>) - .map_err(|_| SqliteError::new(ErrorKind::ExecutionFailed))?; - let rolled_back = connection.execute_batch("ROLLBACK"); - let authorized = install_authorizer(connection); - if rolled_back.is_err() || authorized.is_err() { - return Err(SqliteError::new(ErrorKind::ExecutionFailed)); + .is_ok(); + if !connection.is_autocommit() { + reusable &= connection.execute_batch("ROLLBACK").is_ok(); + } + reusable &= install_authorizer(connection).is_ok(); + reusable &= connection.is_autocommit(); + reusable &= !connection.is_busy(); + if reusable { + Ok(()) + } else { + Err(SqliteError::new(ErrorKind::ExecutionFailed)) } - Ok(()) } fn verify_schema_at_open( @@ -800,12 +920,14 @@ fn verify_schema_at_open( let Some(binding) = binding else { return Ok(()); }; - begin_read_transaction(connection)?; let deadline = deadline(limits.timeout)?; - let budget = install_progress_handler(connection, limits.maximum_statement_steps, deadline)?; - let outcome = schema_fingerprint_with_budget(connection, binding, limits, &budget); - let closed = end_read_transaction(connection); - match (outcome, closed) { + let outcome = begin_read_transaction(connection).and_then(|()| { + let budget = + install_progress_handler(connection, limits.maximum_statement_steps, deadline)?; + schema_fingerprint_with_budget(connection, binding, limits, &budget) + }); + let cleaned = reset_connection_after_read(connection); + match (outcome, cleaned) { (Ok(_), Ok(())) => Ok(()), (Err(error), _) => Err(error), (Ok(_), Err(error)) => Err(error), @@ -860,22 +982,27 @@ fn read_row( ) -> Result { let mut object = BTreeMap::new(); for (index, column) in plan.columns.iter().enumerate() { - charge_response( - response_bytes, - usize::from(index > 0) - .saturating_add(json_string_bytes(column.name.as_bytes())) - .saturating_add(1), - plan.limits.maximum_response_bytes, - )?; + if plan.response_budget_accounting == ResponseBudgetAccounting::SerializedResult { + charge_response( + response_bytes, + usize::from(index > 0) + .saturating_add(json_string_bytes(column.name.as_bytes())) + .saturating_add(1), + plan.limits.maximum_response_bytes, + )?; + } let raw = row .get_ref(index) .map_err(|_| SqliteError::new(ErrorKind::ExecutionFailed))?; let (value, bytes) = read_value(raw, column.value_type, plan.limits.maximum_cell_bytes)?; - charge_response( - response_bytes, - serialized_value_bytes(&value).max(bytes), - plan.limits.maximum_response_bytes, - )?; + let charge = match plan.response_budget_accounting { + ResponseBudgetAccounting::SerializedResult => serialized_value_bytes(&value).max(bytes), + ResponseBudgetAccounting::TextValuesOnly => match &value { + Value::String(_) => bytes, + Value::Null | Value::Integer(_) | Value::Number(_) | Value::Boolean(_) => 0, + }, + }; + charge_response(response_bytes, charge, plan.limits.maximum_response_bytes)?; object.insert(column.name.clone(), value); } Ok(object) @@ -1057,6 +1184,74 @@ pub fn materialize_fixture(target: &Path, seed_sql: &str) -> Result<(), SqliteEr mod tests { use super::*; + fn test_plan(sql: &str, maximum_statement_steps: u64) -> CompiledPlan { + CompiledPlan { + sql: sql.to_owned(), + columns: vec![ColumnContract { + name: "id".to_owned(), + value_type: ColumnType::String, + }], + parameters: Vec::new(), + limits: StatementLimits { + maximum_rows: 2, + maximum_cell_bytes: 32, + maximum_response_bytes: 128, + maximum_statement_steps, + timeout: Duration::from_secs(1), + concurrency: 1, + }, + schema: None, + statement_digest: statement_digest(sql), + response_budget_accounting: ResponseBudgetAccounting::SerializedResult, + } + } + + fn reusable_test_connection() -> Connection { + let connection = Connection::open_in_memory().expect("test connection opens"); + connection + .set_limit(Limit::SQLITE_LIMIT_LENGTH, MAXIMUM_ENGINE_VALUE_BYTES) + .expect("engine limit installs"); + install_authorizer(&connection).expect("reviewed authorizer installs"); + connection + } + + fn assert_failure_leaves_connection_reusable( + connection: &Connection, + plan: &CompiledPlan, + deadline: Instant, + expected: ErrorKind, + ) { + let failed = run_statement(connection, plan, &[], deadline); + assert_eq!( + failed.outcome.expect_err("statement fails").kind(), + expected + ); + assert!( + failed.reusable, + "failed execution must clean the connection" + ); + assert!(connection.is_autocommit(), "transaction must be closed"); + + let answered = run_statement( + connection, + &test_plan("SELECT 'ok' AS id", 100_000), + &[], + Instant::now() + Duration::from_secs(1), + ); + assert!(answered.reusable); + let (rows, _) = answered + .outcome + .expect("the cleaned connection is reusable"); + assert_eq!(rows[0]["id"], Value::String("ok".to_owned())); + let refused = connection + .prepare("SELECT random()") + .expect_err("the reviewed authorizer remains installed"); + assert_eq!( + classify_prepare(&refused, "SELECT random()").kind(), + ErrorKind::AuthorizerRefused + ); + } + #[cfg(unix)] fn database(path: &Path, marker: &str) { use std::os::unix::fs::PermissionsExt as _; @@ -1127,6 +1322,82 @@ mod tests { } } + #[test] + fn step_deadline_engine_and_authorizer_failures_leave_connection_reusable() { + let connection = reusable_test_connection(); + assert_failure_leaves_connection_reusable( + &connection, + &test_plan( + "WITH RECURSIVE counter(n) AS (\ + SELECT 1 UNION ALL SELECT n + 1 FROM counter WHERE n < 50000000\ + ) SELECT printf('%d', COUNT(*)) AS id FROM counter", + 1_000, + ), + Instant::now() + Duration::from_secs(1), + ErrorKind::StepBudgetExceeded, + ); + assert_failure_leaves_connection_reusable( + &connection, + &test_plan("SELECT 'too-late' AS id", 100_000), + Instant::now(), + ErrorKind::TimeBudgetExceeded, + ); + assert_failure_leaves_connection_reusable( + &connection, + &test_plan("SELECT json_extract('not-json', '$') AS id", 100_000), + Instant::now() + Duration::from_secs(1), + ErrorKind::ExecutionFailed, + ); + assert_failure_leaves_connection_reusable( + &connection, + &test_plan("SELECT random() AS id", 100_000), + Instant::now() + Duration::from_secs(1), + ErrorKind::AuthorizerRefused, + ); + } + + #[cfg(unix)] + #[test] + fn a_nonreusable_connection_is_discarded_and_replaced() { + let temporary = tempfile::tempdir().expect("temporary root"); + let path = temporary.path().join("source.sqlite"); + database(&path, "governed"); + let profile = + DatabaseProfile::Snapshot(CapturedSnapshot::capture(&path).expect("snapshot captures")); + let connection = open_replacement_connection(&profile).expect("connection opens"); + connection + .set_limit(Limit::SQLITE_LIMIT_COLUMN, 1) + .expect("old connection is marked"); + let connections = Mutex::new(Vec::new()); + + assert!(return_or_replace_connection( + &connections, + &profile, + connection, + false, + )); + + let replacement = connections + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .pop() + .expect("a replacement is returned to the pool"); + assert!( + replacement + .limit(Limit::SQLITE_LIMIT_COLUMN) + .expect("replacement limit can be read") + > 1 + ); + let answered = run_statement( + &replacement, + &test_plan("SELECT 'replacement' AS id", 100_000), + &[], + Instant::now() + Duration::from_secs(1), + ); + assert!(answered.reusable); + assert!(answered.outcome.is_ok()); + } + #[test] fn positional_parameter_scan_ignores_literals_identifiers_and_comments() { assert!(!contains_positional_parameter( diff --git a/crates/registry-platform-sqlite/tests/kernel.rs b/crates/registry-platform-sqlite/tests/kernel.rs index fcefb5c51..e3c7f29f4 100644 --- a/crates/registry-platform-sqlite/tests/kernel.rs +++ b/crates/registry-platform-sqlite/tests/kernel.rs @@ -264,13 +264,14 @@ fn snapshot_readiness_rehashes_the_exact_captured_bytes() { } #[tokio::test] -async fn the_step_budget_interrupts_an_expensive_statement() { +async fn the_step_budget_interrupts_an_expensive_statement_and_the_pool_recovers() { let directory = TempDir::new().unwrap(); let path = database(&directory); let snapshot = CapturedSnapshot::capture(&path).unwrap(); let mut bounded = contract( "WITH RECURSIVE counter(n) AS (\ - SELECT 1 UNION ALL SELECT n + 1 FROM counter WHERE n < 50000000\ + SELECT 1 UNION ALL SELECT n + 1 FROM counter \ + WHERE n < CASE WHEN :active = 1 THEN 50000000 ELSE 1 END\ ) SELECT printf('%d', COUNT(*)) AS id FROM counter WHERE :active = :active", ); bounded.limits.maximum_statement_steps = 1_000; @@ -280,20 +281,26 @@ async fn the_step_budget_interrupts_an_expensive_statement() { .await .unwrap_err(); assert_eq!(error.kind(), ErrorKind::StepBudgetExceeded); + let recovered = statement + .execute(&BTreeMap::from([("active".to_owned(), Value::Integer(0))])) + .await + .expect("the interrupted connection returns cleanly to the pool"); + assert_eq!(recovered.rows[0]["id"], Value::String("1".to_owned())); } #[tokio::test] -async fn the_time_budget_interrupts_an_expensive_statement() { +async fn the_time_budget_interrupts_an_expensive_statement_and_the_pool_recovers() { let directory = TempDir::new().unwrap(); let path = database(&directory); let snapshot = CapturedSnapshot::capture(&path).unwrap(); let mut bounded = contract( "WITH RECURSIVE counter(n) AS (\ - SELECT 1 UNION ALL SELECT n + 1 FROM counter WHERE n < 50000000\ + SELECT 1 UNION ALL SELECT n + 1 FROM counter \ + WHERE n < CASE WHEN :active = 1 THEN 50000000 ELSE 1 END\ ) SELECT printf('%d', COUNT(*)) AS id FROM counter WHERE :active = :active", ); bounded.limits.maximum_statement_steps = 100_000_000; - bounded.limits.timeout = Duration::from_millis(1); + bounded.limits.timeout = Duration::from_millis(25); let statement = ReadOnlyStatement::open(DatabaseProfile::Snapshot(snapshot), bounded).unwrap(); let error = statement .execute(&BTreeMap::from([("active".to_owned(), Value::Integer(1))])) @@ -303,6 +310,11 @@ async fn the_time_budget_interrupts_an_expensive_statement() { error.kind(), ErrorKind::TimeBudgetExceeded | ErrorKind::Timeout )); + let recovered = statement + .execute(&BTreeMap::from([("active".to_owned(), Value::Integer(0))])) + .await + .expect("the timed-out connection returns cleanly to the pool"); + assert_eq!(recovered.rows[0]["id"], Value::String("1".to_owned())); } #[cfg(feature = "fixture")] From af2b591a88b2c58bc33ad1c163fe3abba2746cbc Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 10 Aug 2026 13:23:27 +0700 Subject: [PATCH 06/24] fix(relay): enforce governed runtime boundaries Signed-off-by: Jeremi Joslin --- Cargo.lock | 87 ++++- Cargo.toml | 1 + crates/registry-relay-v2/Cargo.toml | 3 + crates/registry-relay-v2/src/api.rs | 187 +++++---- crates/registry-relay-v2/src/auth.rs | 15 +- crates/registry-relay-v2/src/compiler.rs | 144 ++++--- crates/registry-relay-v2/src/cursor.rs | 173 +++++++-- crates/registry-relay-v2/src/package.rs | 361 ++++++++++++++--- crates/registry-relay-v2/src/semantics.rs | 148 +++++-- crates/registry-relay-v2/src/tooling.rs | 25 +- .../tests/acceptance_http.rs | 365 +++++++++++++++++- .../tests/multi_resource_isolation.rs | 4 +- 12 files changed, 1173 insertions(+), 340 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 61d25b400..a3372d5a1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.6", + "generic-array 0.14.9", +] + [[package]] name = "ahash" version = "0.8.12" @@ -878,6 +888,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + [[package]] name = "chacha20" version = "0.10.1" @@ -889,6 +910,19 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20 0.9.1", + "cipher", + "poly1305", + "zeroize", +] + [[package]] name = "chrono" version = "0.4.45" @@ -962,6 +996,17 @@ dependencies = [ "half", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.6", + "inout", + "zeroize", +] + [[package]] name = "clap" version = "4.6.1" @@ -1470,6 +1515,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array 0.14.9", + "rand_core 0.6.4", "typenum", ] @@ -3685,6 +3731,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array 0.14.9", +] + [[package]] name = "inquire" version = "0.9.4" @@ -4622,6 +4677,12 @@ version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "openssl" version = "0.10.81" @@ -5021,6 +5082,17 @@ dependencies = [ "plotters-backend", ] +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -5357,7 +5429,7 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" dependencies = [ - "chacha20", + "chacha20 0.10.1", "getrandom 0.4.3", "rand_core 0.10.1", ] @@ -6204,14 +6276,17 @@ dependencies = [ "axum", "base64", "bytes", + "chacha20poly1305", "chrono", "clap", "futures", + "getrandom 0.4.3", "hex", "hmac 0.13.0", "http", "jsonwebtoken", "registry-platform-audit", + "registry-platform-authcommon", "registry-platform-buildinfo", "registry-platform-canonical-json", "registry-platform-config", @@ -8107,6 +8182,16 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.6", + "subtle", +] + [[package]] name = "unsafe-libyaml" version = "0.2.11" diff --git a/Cargo.toml b/Cargo.toml index 54d408c9c..1f2dedd6f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -92,6 +92,7 @@ base64 = { version = "0.22" } bytes = { version = "1" } calamine = { version = "0.36" } cel = { version = "0.13" } +chacha20poly1305 = { version = "0.10" } chrono = { version = "0.4" } chrono-tz = { version = "0.10.4" } clap = { version = "4", features = ["derive", "env"] } diff --git a/crates/registry-relay-v2/Cargo.toml b/crates/registry-relay-v2/Cargo.toml index 72e3efb12..e7cf1254d 100644 --- a/crates/registry-relay-v2/Cargo.toml +++ b/crates/registry-relay-v2/Cargo.toml @@ -24,13 +24,16 @@ tooling = ["dep:tempfile", "registry-platform-sqlite/fixture"] axum.workspace = true base64.workspace = true bytes.workspace = true +chacha20poly1305.workspace = true chrono = { workspace = true, features = ["serde"] } clap.workspace = true +getrandom.workspace = true hex.workspace = true hmac.workspace = true http.workspace = true jsonwebtoken.workspace = true registry-platform-audit.workspace = true +registry-platform-authcommon.workspace = true registry-platform-buildinfo.workspace = true registry-platform-canonical-json.workspace = true registry-platform-config.workspace = true diff --git a/crates/registry-relay-v2/src/api.rs b/crates/registry-relay-v2/src/api.rs index a911ee897..f7c2b36ce 100644 --- a/crates/registry-relay-v2/src/api.rs +++ b/crates/registry-relay-v2/src/api.rs @@ -389,17 +389,26 @@ pub async fn record_list( uri: Uri, ) -> Response { let trace = TraceContext::from_headers(&headers); + let principal = match authenticate_data_request(&service, &headers, &trace).await { + Ok(value) => value, + Err(response) => return response, + }; let Some((resource, operation)) = find_operation(&service, &resource_id, |kind| { matches!(kind, OperationKind::List) }) else { - return unknown_data_route(&service, &headers, &trace, OperationClass::List).await; + return unknown_data_route(&service, principal.as_ref(), &trace, OperationClass::List) + .await; + }; + let access = match access_operation(&service, resource, operation, principal, &trace).await { + Ok(value) => value, + Err(response) => return response, }; if !uri_within_bound(&uri) { return refuse_known( &service, resource, operation, - None, + Some(&access), AuditOutcome::InvalidRequest, ProblemCode::UriTooLong, &trace, @@ -411,7 +420,7 @@ pub async fn record_list( resource, operation, uri.query(), - &headers, + principal, &trace, ) .await @@ -549,16 +558,7 @@ pub async fn record_list( &result.source_revision, ) { Ok(value) => Some(value), - Err(_) => { - return terminal_problem( - &service.audit, - &audit, - AuditOutcome::InternalFailed, - ProblemCode::Internal, - &trace, - ) - .await - } + Err(_) => return source_shape_failure(&service.audit, &audit, &trace).await, } } else { None @@ -601,10 +601,28 @@ pub async fn record_read( uri: Uri, ) -> Response { let trace = TraceContext::from_headers(&headers); + let principal = match authenticate_data_request(&service, &headers, &trace).await { + Ok(value) => value, + Err(response) => return response, + }; let Some((resource, operation)) = find_operation(&service, &resource_id, |kind| { matches!(kind, OperationKind::Read) }) else { - return unknown_data_route(&service, &headers, &trace, OperationClass::Read).await; + return unknown_data_route(&service, principal.as_ref(), &trace, OperationClass::Read) + .await; + }; + let access = match access_operation( + &service, + resource, + operation, + uri.query(), + principal, + &trace, + ) + .await + { + Ok(value) => value, + Err(response) => return response, }; if !uri_within_bound(&uri) { return refuse_known( @@ -669,14 +687,31 @@ pub async fn record_lookup( request: Request, ) -> Response { let trace = TraceContext::from_headers(request.headers()); + let principal = match authenticate_data_request(&service, request.headers(), &trace).await { + Ok(value) => value, + Err(response) => return response, + }; let Some((resource, operation)) = find_operation( &service, &resource_id, |kind| matches!(kind, OperationKind::Lookup { name } if name == &lookup_id), ) else { - return unknown_data_route(&service, request.headers(), &trace, OperationClass::Lookup) + return unknown_data_route(&service, principal.as_ref(), &trace, OperationClass::Lookup) .await; }; + let access = match access_operation( + &service, + resource, + operation, + request.uri().query(), + principal, + &trace, + ) + .await + { + Ok(value) => value, + Err(response) => return response, + }; if !uri_within_bound(request.uri()) { return refuse_known( &service, @@ -969,16 +1004,6 @@ async fn single_operation( ) { Ok(value) => value, Err(RecordError::InvalidSource | RecordError::InvalidCore) => { - if matches!(operation.kind, OperationKind::Lookup { .. }) { - return terminal_problem( - &service.audit, - &audit, - AuditOutcome::Unresolved, - ProblemCode::ConsultationUnresolved, - trace, - ) - .await; - } return source_shape_failure(&service.audit, &audit, trace).await; } }; @@ -1017,24 +1042,9 @@ async fn access_operation( resource: &CompiledResource, operation: &CompiledOperation, query: Option<&str>, - headers: &HeaderMap, + principal: Option, trace: &TraceContext, ) -> Result> { - let principal = match optional_principal(service, headers).await { - Ok(value) => value, - Err(code) => { - return Err(refuse_before_representation( - service, - resource, - operation, - PrincipalKind::Unknown, - AuditOutcome::InvalidCredential, - code, - trace, - ) - .await); - } - }; let selected = match select_representation(operation, query) { Ok(value) => value, Err(code) => { @@ -1073,6 +1083,16 @@ async fn access_operation( representation: representation.clone(), }), Err(error) => { + if error == AuthorizationError::ScopeDenied { + return Err(refuse_unknown( + service, + PrincipalKind::Authenticated, + AuditOutcome::NotFound, + ProblemCode::ResourceNotFound, + trace, + ) + .await); + } let (code, outcome) = match error { AuthorizationError::AuthenticationRequired => ( ProblemCode::MissingCredential, @@ -1106,6 +1126,24 @@ async fn access_operation( } } +async fn authenticate_data_request( + service: &RelayService, + headers: &HeaderMap, + trace: &TraceContext, +) -> Result, Response> { + match optional_principal(service, headers).await { + Ok(principal) => Ok(principal), + Err(code) => Err(refuse_unknown( + service, + PrincipalKind::Unknown, + AuditOutcome::InvalidCredential, + code, + trace, + ) + .await), + } +} + async fn optional_principal( service: &RelayService, headers: &HeaderMap, @@ -1149,25 +1187,10 @@ enum OperationClass { async fn unknown_data_route( service: &RelayService, - headers: &HeaderMap, + principal: Option<&Principal>, trace: &TraceContext, class: OperationClass, ) -> Response { - let principal = match optional_principal(service, headers).await { - Ok(value) => value, - Err(code) => { - let audit = unknown_audit_context(service, trace, PrincipalKind::Unknown); - if service - .audit - .refusal(&audit, AuditOutcome::InvalidCredential) - .await - .is_err() - { - return ProblemCode::AuditUnavailable.response(trace); - } - return code.response(trace); - } - }; let protected = service.registry.resources.iter().any(|resource| { resource.operations.iter().any(|operation| { class_matches(&operation.kind, class) @@ -1177,35 +1200,27 @@ async fn unknown_data_route( }) }); if protected && principal.is_none() { - let audit = unknown_audit_context(service, trace, PrincipalKind::Unknown); - if service - .audit - .refusal(&audit, AuditOutcome::MissingCredential) - .await - .is_err() - { - return ProblemCode::AuditUnavailable.response(trace); - } - return ProblemCode::MissingCredential.response(trace); + return refuse_unknown( + service, + PrincipalKind::Unknown, + AuditOutcome::MissingCredential, + ProblemCode::MissingCredential, + trace, + ) + .await; } - let audit = unknown_audit_context( + refuse_unknown( service, - trace, if principal.is_some() { PrincipalKind::Authenticated } else { PrincipalKind::Anonymous }, - ); - if service - .audit - .refusal(&audit, AuditOutcome::NotFound) - .await - .is_err() - { - return ProblemCode::AuditUnavailable.response(trace); - } - ProblemCode::ResourceNotFound.response(trace) + AuditOutcome::NotFound, + ProblemCode::ResourceNotFound, + trace, + ) + .await } fn class_matches(kind: &OperationKind, class: OperationClass) -> bool { @@ -1217,6 +1232,20 @@ fn class_matches(kind: &OperationKind, class: OperationClass) -> bool { ) } +async fn refuse_unknown( + service: &RelayService, + principal_kind: PrincipalKind, + outcome: AuditOutcome, + code: ProblemCode, + trace: &TraceContext, +) -> Response { + let audit = unknown_audit_context(service, trace, principal_kind); + if service.audit.refusal(&audit, outcome).await.is_err() { + return ProblemCode::AuditUnavailable.response(trace); + } + code.response(trace) +} + async fn refuse_known( service: &RelayService, resource: &CompiledResource, diff --git a/crates/registry-relay-v2/src/auth.rs b/crates/registry-relay-v2/src/auth.rs index 4f32f7beb..f13c3fe0f 100644 --- a/crates/registry-relay-v2/src/auth.rs +++ b/crates/registry-relay-v2/src/auth.rs @@ -10,6 +10,7 @@ use base64::engine::general_purpose::URL_SAFE_NO_PAD; use base64::Engine as _; use http::header::AUTHORIZATION; use http::HeaderMap; +use registry_platform_authcommon::parse_bearer_token; use registry_platform_oidc::{Audience, TokenVerifier, VerifiedToken}; use serde::de::{self, DeserializeSeed as _, Visitor}; use serde_json::{Map, Value}; @@ -358,12 +359,7 @@ pub fn bearer_token(headers: &HeaderMap) -> Result, AuthenticationE return Err(AuthenticationError::Malformed); } let value = first.to_str().map_err(|_| AuthenticationError::Malformed)?; - let token = value - .strip_prefix("Bearer ") - .ok_or(AuthenticationError::Malformed)?; - if token.is_empty() || token.bytes().any(|byte| byte.is_ascii_whitespace()) { - return Err(AuthenticationError::Malformed); - } + let token = parse_bearer_token(value).map_err(|_| AuthenticationError::Malformed)?; Ok(Some(token)) } @@ -588,6 +584,13 @@ mod tests { assert_eq!(bearer_token(&headers), Err(AuthenticationError::Malformed)); } + #[test] + fn bearer_scheme_is_ascii_case_insensitive() { + let mut headers = HeaderMap::new(); + headers.insert(AUTHORIZATION, "bEaReR abc".parse().expect("header")); + assert_eq!(bearer_token(&headers), Ok(Some("abc"))); + } + #[test] fn verified_token_must_contain_not_before() { let claims: Claims = serde_json::from_value(serde_json::json!({ diff --git a/crates/registry-relay-v2/src/compiler.rs b/crates/registry-relay-v2/src/compiler.rs index 8a6b7f5d3..230aaf288 100644 --- a/crates/registry-relay-v2/src/compiler.rs +++ b/crates/registry-relay-v2/src/compiler.rs @@ -42,6 +42,35 @@ const MAXIMUM_PARTIAL_STRING_CHARACTERS: u16 = 64; pub type GovernedFileSet = BTreeMap>; +pub(crate) fn referenced_governed_files(contract: &RegistryContract) -> BTreeSet<&str> { + let mut references = BTreeSet::new(); + references.insert(contract.registry.identifier_lifecycle_policy_ref.as_str()); + references.insert(contract.classifications.provenance_ref.as_str()); + for alignment in &contract.semantics.alignments { + references.insert(alignment.profile_ref.as_str()); + } + for resource in &contract.resources { + references.insert(resource.record_context.lifecycle_state.codelist.as_str()); + for (_, property) in resource.properties.iter() { + if let Some(codelist) = property.codelist.as_deref() { + references.insert(codelist); + } + } + for lookup in &resource.operations.lookups { + for (_, selector) in lookup.request_body.selectors.iter() { + if let Some(codelist) = selector.codelist.as_deref() { + references.insert(codelist); + } + } + } + for processing in &resource.processing_descriptions { + references.insert(processing.legal_basis_ref.as_str()); + references.insert(processing.dpv_profile_ref.as_str()); + } + } + references +} + pub fn compile_yaml( yaml: &str, observed: &[ObservedSourceSchema], @@ -219,6 +248,16 @@ fn governed_file_roles( roles.push(format!("codelist:{}:{property}", resource.id)); } } + for lookup in &resource.operations.lookups { + for (selector, definition) in lookup.request_body.selectors.iter() { + if definition.codelist.as_deref() == Some(path) { + roles.push(format!( + "codelist:{}:lookup:{}:{selector}", + resource.id, lookup.id + )); + } + } + } for processing in &resource.processing_descriptions { if processing.legal_basis_ref == path { roles.push(format!( @@ -1467,6 +1506,13 @@ impl<'a> Compiler<'a> { "fixed order properties must be required in the governed source contract", ); } + if !cursor_order_type_supported(property.data_type) { + self.error( + "list.order_property_type_unsupported", + &format!("{location}.orderBy"), + "fixed order properties must use a cursor-supported string, integer, or boolean value shape", + ); + } if !order_columns.insert(property.source_column.as_str()) { self.error( "list.order_column_duplicate", @@ -1537,10 +1583,10 @@ impl<'a> Compiler<'a> { }) else { return; }; - // SQLite does not preserve NOT NULL metadata through views: even a + // SQLite does not preserve NOT NULL metadata through views. Even a // direct projection of a NOT NULL base-table column is reported as // nullable by PRAGMA table_xinfo(view). The authored sourceRequired - // contract and runtime value validation therefore own null rejection; + // contract and runtime source-row validation own null rejection; // observed metadata still closes the declared scalar type here. if !compatible_declared_type(data_type, &observed.declared_type) { self.error( @@ -1551,70 +1597,6 @@ impl<'a> Compiler<'a> { } } - fn compile_transform( - &mut self, - definition: Option<&TransformDefinition>, - output_type: DataType, - location: &str, - ) -> Option { - match definition? { - TransformDefinition::PartialString { reveal, characters } => { - if output_type != DataType::String { - self.error( - "transform.output_type_invalid", - &format!("{location}.type"), - "partial-string transforms must publish a string property", - ); - } - if !(1..=MAXIMUM_PARTIAL_STRING_CHARACTERS).contains(characters) { - self.error( - "transform.partial_string_characters_invalid", - &format!("{location}.transform.characters"), - "partial-string reveal length must be within the fixed product bound", - ); - } - let reveal_label = match reveal { - crate::contract::PartialStringReveal::Prefix => "prefix", - crate::contract::PartialStringReveal::Suffix => "suffix", - }; - Some(CompiledTransform::PartialString { - identifier: format!("partial-string:{reveal_label}:{characters}"), - reveal: *reveal, - characters: *characters, - }) - } - TransformDefinition::DatePrecision { - source_type, - precision, - } => { - let expected_output = match precision { - DatePrecision::Year => DataType::Year, - DatePrecision::YearMonth => DataType::YearMonth, - }; - if output_type != expected_output { - self.error( - "transform.output_type_invalid", - &format!("{location}.type"), - "date-precision output datatype must match the selected precision", - ); - } - let source_label = match source_type { - DateInputType::Date => "date", - DateInputType::DateTime => "date-time", - }; - let precision_label = match precision { - DatePrecision::Year => "year", - DatePrecision::YearMonth => "year-month", - }; - Some(CompiledTransform::DatePrecision { - identifier: format!("date-precision:{source_label}:{precision_label}"), - source_type: *source_type, - precision: *precision, - }) - } - } - } - fn compile_access( &mut self, access: &AccessRule, @@ -2461,6 +2443,13 @@ fn validate_governed_files( codelist_paths.insert(codelist); } } + for lookup in &resource.operations.lookups { + for (_, selector) in lookup.request_body.selectors.iter() { + if let Some(codelist) = selector.codelist.as_deref() { + codelist_paths.insert(codelist); + } + } + } for processing in &resource.processing_descriptions { sidecar_paths.insert(processing.legal_basis_ref.as_str()); sidecar_paths.insert(processing.dpv_profile_ref.as_str()); @@ -2848,19 +2837,16 @@ fn compatible_declared_type(data_type: DataType, declared_type: &str) -> bool { } } -fn transform_source_type(transform: Option<&CompiledTransform>, output_type: DataType) -> DataType { - match transform { - Some(CompiledTransform::PartialString { .. }) => DataType::String, - Some(CompiledTransform::DatePrecision { - source_type: DateInputType::Date, - .. - }) => DataType::Date, - Some(CompiledTransform::DatePrecision { - source_type: DateInputType::DateTime, - .. - }) => DataType::DateTime, - None => output_type, - } +fn cursor_order_type_supported(data_type: DataType) -> bool { + matches!( + data_type, + DataType::String + | DataType::ControlledCode + | DataType::Date + | DataType::DateTime + | DataType::Integer + | DataType::Boolean + ) } fn validate_observed_schema( diff --git a/crates/registry-relay-v2/src/cursor.rs b/crates/registry-relay-v2/src/cursor.rs index c1a12920c..233c2bdce 100644 --- a/crates/registry-relay-v2/src/cursor.rs +++ b/crates/registry-relay-v2/src/cursor.rs @@ -1,5 +1,5 @@ // SPDX-License-Identifier: Apache-2.0 -//! Opaque, integrity-protected keyset cursors. +//! Client-opaque, confidential and integrity-protected keyset cursors. use std::collections::BTreeMap; use std::fmt; @@ -7,6 +7,8 @@ use std::time::{SystemTime, UNIX_EPOCH}; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use base64::Engine as _; +use chacha20poly1305::aead::{Aead, KeyInit as _, Payload}; +use chacha20poly1305::{XChaCha20Poly1305, XNonce}; use hmac::{Hmac, KeyInit, Mac}; use serde::{Deserialize, Serialize}; use sha2::Sha256; @@ -15,7 +17,11 @@ use zeroize::Zeroizing; const CURSOR_VERSION: u8 = 2; const MAX_CURSOR_BYTES: usize = 8 * 1024; -const MAC_BYTES: usize = 32; +const KEY_BYTES: usize = 32; +const NONCE_BYTES: usize = 24; +const TAG_BYTES: usize = 16; +const ENVELOPE_OVERHEAD: usize = 1 + NONCE_BYTES + TAG_BYTES; +const CURSOR_AAD: &[u8] = b"registry-relay-v2-cursor-v2"; type HmacSha256 = Hmac; @@ -115,15 +121,20 @@ impl CursorPayload { } } -/// Cursor HMAC key. `Debug` intentionally cannot expose key material. -pub struct CursorKey(Zeroizing>); +/// Cursor protection key. `Debug` intentionally cannot expose key material. +pub struct CursorKey(Zeroizing<[u8; KEY_BYTES]>); impl CursorKey { pub fn new(bytes: Vec) -> Result { - if bytes.len() < MAC_BYTES { + if bytes.len() < KEY_BYTES { return Err(CursorError::Configuration); } - Ok(Self(Zeroizing::new(bytes))) + let bytes = Zeroizing::new(bytes); + let mut derivation = + HmacSha256::new_from_slice(bytes.as_slice()).map_err(|_| CursorError::Configuration)?; + derivation.update(b"registry-relay-v2-cursor-key-v2"); + let derived: [u8; KEY_BYTES] = derivation.finalize().into_bytes().into(); + Ok(Self(Zeroizing::new(derived))) } /// Domain-separated binding used for authorization and query-context @@ -154,7 +165,7 @@ pub enum CursorError { Configuration, #[error("cursor is malformed")] Malformed, - #[error("cursor signature is invalid")] + #[error("cursor protection is invalid")] Integrity, #[error("cursor is expired")] Expired, @@ -163,17 +174,28 @@ pub enum CursorError { } pub fn encode(key: &CursorKey, payload: &CursorPayload) -> Result { - let encoded = serde_json::to_vec(payload).map_err(|_| CursorError::Malformed)?; - if encoded.is_empty() || encoded.len() > MAX_CURSOR_BYTES { + let plaintext = serde_json::to_vec(payload).map_err(|_| CursorError::Malformed)?; + if plaintext.is_empty() || plaintext.len() > MAX_CURSOR_BYTES { return Err(CursorError::Malformed); } - let mut mac = - HmacSha256::new_from_slice(key.0.as_slice()).map_err(|_| CursorError::Configuration)?; - mac.update(&encoded); - let signature = mac.finalize().into_bytes(); - let mut envelope = Vec::with_capacity(encoded.len() + MAC_BYTES); - envelope.extend_from_slice(&encoded); - envelope.extend_from_slice(&signature); + let cipher = XChaCha20Poly1305::new_from_slice(key.0.as_slice()) + .map_err(|_| CursorError::Configuration)?; + let mut nonce = [0_u8; NONCE_BYTES]; + getrandom::fill(&mut nonce).map_err(|_| CursorError::Configuration)?; + let nonce_value = XNonce::from(nonce); + let ciphertext = cipher + .encrypt( + &nonce_value, + Payload { + msg: plaintext.as_slice(), + aad: CURSOR_AAD, + }, + ) + .map_err(|_| CursorError::Configuration)?; + let mut envelope = Vec::with_capacity(1 + nonce.len() + ciphertext.len()); + envelope.push(CURSOR_VERSION); + envelope.extend_from_slice(&nonce); + envelope.extend_from_slice(&ciphertext); Ok(URL_SAFE_NO_PAD.encode(envelope)) } @@ -182,23 +204,34 @@ pub fn decode( encoded: &str, now_unix_seconds: u64, ) -> Result { - if encoded.is_empty() || encoded.len() > MAX_CURSOR_BYTES * 2 { + if encoded.is_empty() || encoded.len() > (MAX_CURSOR_BYTES + ENVELOPE_OVERHEAD) * 2 { return Err(CursorError::Malformed); } let envelope = URL_SAFE_NO_PAD .decode(encoded) .map_err(|_| CursorError::Malformed)?; - if envelope.len() <= MAC_BYTES || envelope.len() > MAX_CURSOR_BYTES + MAC_BYTES { + if envelope.len() <= ENVELOPE_OVERHEAD + || envelope.len() > MAX_CURSOR_BYTES + ENVELOPE_OVERHEAD + || envelope[0] != CURSOR_VERSION + { return Err(CursorError::Malformed); } - let (payload_bytes, supplied_signature) = envelope.split_at(envelope.len() - MAC_BYTES); - let mut mac = - HmacSha256::new_from_slice(key.0.as_slice()).map_err(|_| CursorError::Configuration)?; - mac.update(payload_bytes); - mac.verify_slice(supplied_signature) + let (nonce, ciphertext) = envelope[1..].split_at(NONCE_BYTES); + let nonce: [u8; NONCE_BYTES] = nonce.try_into().map_err(|_| CursorError::Malformed)?; + let nonce = XNonce::from(nonce); + let cipher = XChaCha20Poly1305::new_from_slice(key.0.as_slice()) + .map_err(|_| CursorError::Configuration)?; + let payload_bytes = cipher + .decrypt( + &nonce, + Payload { + msg: ciphertext, + aad: CURSOR_AAD, + }, + ) .map_err(|_| CursorError::Integrity)?; let payload: CursorPayload = - serde_json::from_slice(payload_bytes).map_err(|_| CursorError::Malformed)?; + serde_json::from_slice(&payload_bytes).map_err(|_| CursorError::Malformed)?; if payload.version != CURSOR_VERSION { return Err(CursorError::Malformed); } @@ -261,18 +294,39 @@ mod tests { } #[test] - fn cursor_is_opaque_and_refuses_tampering() { + fn cursor_conceals_order_values_and_refuses_tampering() { let key = CursorKey::new(vec![7; 32]).expect("key is sufficient"); - let encoded = encode(&key, &payload()).expect("cursor encodes"); - assert!(!encoded.contains("record-1")); - let mut tampered = encoded.into_bytes(); - let final_byte = tampered.len() - 1; - tampered[final_byte] = if tampered[final_byte] == b'A' { - b'B' - } else { - b'A' - }; - let tampered = String::from_utf8(tampered).expect("cursor stays text"); + let mut protected = payload(); + protected.last_record_identifier = "protected-record-id-canary".to_owned(); + protected.filters.insert( + "status".to_owned(), + CursorValue::String("protected-filter-value-canary".to_owned()), + ); + protected.selected_fields = vec!["omitted-field-name-canary".to_owned()]; + protected.last_order_values = vec![CursorValue::String( + "protected-order-value-canary".to_owned(), + )]; + let encoded = encode(&key, &protected).expect("cursor encodes"); + let mut envelope = URL_SAFE_NO_PAD + .decode(&encoded) + .expect("cursor is base64url"); + for canary in [ + b"protected-record-id-canary".as_slice(), + b"protected-filter-value-canary".as_slice(), + b"omitted-field-name-canary".as_slice(), + b"protected-order-value-canary".as_slice(), + ] { + assert!(!envelope + .windows(canary.len()) + .any(|window| window == canary)); + } + assert_eq!( + decode(&key, &encoded, 1).expect("cursor decrypts"), + protected + ); + let final_byte = envelope.len() - 1; + envelope[final_byte] ^= 1; + let tampered = URL_SAFE_NO_PAD.encode(envelope); assert!(matches!( decode(&key, &tampered, 1), Err(CursorError::Integrity) | Err(CursorError::Malformed) @@ -280,13 +334,50 @@ mod tests { } #[test] - fn cursor_cannot_cross_authorization_or_filter_contexts() { + fn encrypting_the_same_cursor_twice_uses_distinct_nonces() { + let key = CursorKey::new(vec![7; 32]).expect("key is sufficient"); + let first = encode(&key, &payload()).expect("first cursor encodes"); + let second = encode(&key, &payload()).expect("second cursor encodes"); + assert_ne!(first, second); + } + + #[test] + fn cursor_refuses_every_mismatched_request_binding_and_expiry() { + let expected = payload(); + let mut mismatches = Vec::new(); + let mut request = payload(); - request.authorization_digest = "sha256:other".to_owned(); - assert_eq!( - require_same_request(&payload(), &request), - Err(CursorError::Mismatch) - ); + request.contract_revision = "sha256:other-contract".to_owned(); + mismatches.push(request); + let mut request = payload(); + request.source_revision = "sha256:other-source".to_owned(); + mismatches.push(request); + let mut request = payload(); + request.operation = "other.list".to_owned(); + mismatches.push(request); + let mut request = payload(); + request.filters_digest = "sha256:other-filters".to_owned(); + mismatches.push(request); + let mut request = payload(); + request.selected_fields_digest = "sha256:other-fields".to_owned(); + mismatches.push(request); + let mut request = payload(); + request.authorization_digest = "sha256:other-authorization".to_owned(); + mismatches.push(request); + let mut request = payload(); + request.order_digest = "sha256:other-order".to_owned(); + mismatches.push(request); + + for request in mismatches { + assert_eq!( + require_same_request(&expected, &request), + Err(CursorError::Mismatch) + ); + } + + let key = CursorKey::new(vec![7; 32]).expect("key is sufficient"); + let encoded = encode(&key, &expected).expect("cursor encodes"); + assert_eq!(decode(&key, &encoded, 100), Err(CursorError::Expired)); } #[test] diff --git a/crates/registry-relay-v2/src/package.rs b/crates/registry-relay-v2/src/package.rs index 1d76a04ec..04be36d65 100644 --- a/crates/registry-relay-v2/src/package.rs +++ b/crates/registry-relay-v2/src/package.rs @@ -10,14 +10,20 @@ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use thiserror::Error; -use crate::artifacts::{generate_artifacts, ArtifactSet, GeneratedArtifact}; -use crate::compiler::{compile_contract_with_governed_files, GovernedFileSet}; +use crate::artifacts::{ArtifactSet, GeneratedArtifact, OperationArtifactBindings}; +use crate::compiler::{ + compile_contract_with_governed_files, referenced_governed_files, GovernedFileSet, +}; use crate::contract::{RegistryContract, Visibility}; use crate::model::{ CompileProfile, CompiledClassificationReview, CompiledRegistry, ObservedSourceSchema, }; -const PACKAGE_VERSION: &str = "relay.registrystack.org/package/v1alpha1"; +#[cfg(test)] +use crate::artifacts::generate_artifacts; + +const PACKAGE_VERSION: &str = "relay.registrystack.org/package/v1alpha2"; +const COMPILED_REGISTRY_PATH: &str = "compiled/registry.json"; const MAX_AUTHORED_FILES: usize = 256; const MAX_AUTHORED_BYTES: u64 = 16 * 1024 * 1024; const MAX_PACKAGE_FILES: usize = 1_024; @@ -33,6 +39,7 @@ pub struct PackageManifest { pub source_schema_fingerprints: BTreeMap, pub source_schemas: BTreeMap, pub artifacts: Vec, + pub operation_artifact_bindings: Vec, pub files: Vec, } @@ -102,8 +109,16 @@ pub fn build_package( contract, compiled.classification_review.as_ref(), )?; - let mut files = Vec::new(); let registry_bytes = read_regular(&project_root.join("registry.yaml"))?; + let packaged_contract = RegistryContract::parse_yaml( + std::str::from_utf8(®istry_bytes).map_err(|_| PackageError::Verification)?, + ) + .map_err(|_| PackageError::Verification)?; + if packaged_contract != *contract { + return Err(PackageError::Verification); + } + validate_build_inputs(contract, compiled, artifacts, &authored)?; + let mut files = Vec::new(); files.push(file_entry( "registry.yaml", ®istry_bytes, @@ -120,6 +135,17 @@ pub fn build_package( false, )); } + let compiled_bytes = canonicalize_json( + &serde_json::to_value(compiled).map_err(|_| PackageError::CanonicalJson)?, + ) + .map_err(|_| PackageError::CanonicalJson)?; + files.push(file_entry( + COMPILED_REGISTRY_PATH, + &compiled_bytes, + "application/json", + Visibility::OperatorOnly, + true, + )); for artifact in &artifacts.artifacts { files.push(file_entry( &format!("generated/{}", artifact.path), @@ -171,6 +197,7 @@ pub fn build_package( source_schema_fingerprints: &source_schema_fingerprints, source_schemas: &source_schemas, artifacts: &packaged_artifacts, + operation_artifact_bindings: &artifacts.operation_bindings, files: &files, }; let manifest_value = serde_json::to_value(unsigned).map_err(|_| PackageError::CanonicalJson)?; @@ -184,6 +211,7 @@ pub fn build_package( source_schema_fingerprints, source_schemas, artifacts: packaged_artifacts, + operation_artifact_bindings: artifacts.operation_bindings.clone(), files, }; let final_manifest = canonicalize_json( @@ -197,6 +225,7 @@ pub fn build_package( for (relative, content) in &authored { write_new_file(&output_dir.join("governed").join(relative), content)?; } + write_new_file(&output_dir.join(COMPILED_REGISTRY_PATH), &compiled_bytes)?; for artifact in &artifacts.artifacts { write_generated(output_dir, artifact)?; } @@ -212,6 +241,80 @@ pub fn build_package( Ok(manifest) } +fn validate_build_inputs( + contract: &RegistryContract, + compiled: &CompiledRegistry, + artifacts: &ArtifactSet, + governed: &GovernedFileSet, +) -> Result<(), PackageError> { + if artifacts.contract_revision != compiled.contract_revision + || compiled.contract_id != contract.metadata.id + || compiled.contract_version != contract.metadata.version + || compiled.registry_identifier != contract.registry.registry_identifier + { + return Err(PackageError::Verification); + } + let observed = compiled + .sources + .iter() + .map(|source| { + source + .observed_schema + .clone() + .ok_or(PackageError::Verification) + }) + .collect::, _>>()?; + verify_compiled_derivation(contract, compiled, governed, &observed)?; + let operation_identifiers = compiled + .resources + .iter() + .flat_map(|resource| resource.operations.iter()) + .map(|operation| operation.identifier.as_str()) + .collect::>(); + let mut artifact_ids = BTreeSet::new(); + let mut artifact_paths = BTreeSet::new(); + for artifact in &artifacts.artifacts { + validate_relative(&artifact.path)?; + if !artifact_ids.insert(artifact.id.as_str()) + || !artifact_paths.insert(artifact.path.as_str()) + || artifact.sha256 != digest(&artifact.content) + || artifact + .operation_identifier + .as_deref() + .is_some_and(|identifier| !operation_identifiers.contains(identifier)) + { + return Err(PackageError::Verification); + } + } + if !valid_operation_artifact_bindings( + &artifacts.operation_bindings, + &operation_identifiers, + &artifact_paths, + ) { + return Err(PackageError::Verification); + } + Ok(()) +} + +fn verify_compiled_derivation( + contract: &RegistryContract, + compiled: &CompiledRegistry, + governed: &GovernedFileSet, + observed: &[ObservedSourceSchema], +) -> Result<(), PackageError> { + let reproduced = compile_contract_with_governed_files( + contract, + observed, + CompileProfile::Production, + governed, + ) + .map_err(|_| PackageError::Verification)?; + if reproduced != *compiled { + return Err(PackageError::Verification); + } + Ok(()) +} + /// Load and verify a sealed package before any listener, issuer, audit sink, /// or SQLite source is activated. pub fn load_package(package_path: &Path) -> Result { @@ -250,6 +353,7 @@ pub fn load_package(package_path: &Path) -> Result Result(); if manifest.source_schemas.keys().collect::>() != manifest .source_schema_fingerprints @@ -336,19 +433,42 @@ pub fn load_package(package_path: &Path) -> Result>(); - let registry = compile_contract_with_governed_files( - &contract, - &observed, - CompileProfile::Production, - &governed, + if compiled_entry.len() != 1 + || !compiled_entry[0].generated + || compiled_entry[0].visibility != Visibility::OperatorOnly + || compiled_entry[0].media_type != "application/json" + { + return Err(PackageError::Verification); + } + let compiled_bytes = loaded + .get(COMPILED_REGISTRY_PATH) + .ok_or(PackageError::Verification)?; + let compiled_value: serde_json::Value = + serde_json::from_slice(compiled_bytes).map_err(|_| PackageError::Verification)?; + if canonicalize_json(&compiled_value).map_err(|_| PackageError::CanonicalJson)? + != *compiled_bytes + { + return Err(PackageError::Verification); + } + let registry: CompiledRegistry = + serde_json::from_value(compiled_value).map_err(|_| PackageError::Verification)?; + let reproduced_compiled = canonicalize_json( + &serde_json::to_value(®istry).map_err(|_| PackageError::CanonicalJson)?, ) - .map_err(|_| PackageError::Verification)?; + .map_err(|_| PackageError::CanonicalJson)?; + if reproduced_compiled != *compiled_bytes { + return Err(PackageError::Verification); + } if registry.contract_revision != manifest.contract_revision + || registry.contract_id != contract.metadata.id + || registry.contract_version != contract.metadata.version + || registry.registry_identifier != contract.registry.registry_identifier || registry .sources .iter() @@ -364,35 +484,109 @@ pub fn load_package(package_path: &Path) -> Result(); + let observed = manifest + .source_schemas + .values() + .cloned() .collect::>(); - if expected_artifacts != manifest.artifacts { + verify_compiled_derivation(&contract, ®istry, &governed, &observed)?; + + let governed_paths = registry + .governed_files + .iter() + .map(|file| file.path.as_str()) + .collect::>(); + let loaded_governed_paths = loaded + .keys() + .filter_map(|path| path.strip_prefix("governed/")) + .collect::>(); + if governed_paths != loaded_governed_paths + || registry.governed_files.iter().any(|file| { + loaded + .get(&format!("governed/{}", file.path)) + .is_none_or(|content| digest(content) != file.sha256) + }) + { return Err(PackageError::Verification); } - let mut artifacts = regenerated; - for artifact in &mut artifacts.artifacts { - let packaged_path = format!("generated/{}", artifact.path); - let packaged = loaded - .get(&packaged_path) + + let operation_identifiers = registry + .resources + .iter() + .flat_map(|resource| resource.operations.iter()) + .map(|operation| operation.identifier.as_str()) + .collect::>(); + let mut artifact_ids = BTreeSet::new(); + let mut artifact_paths = BTreeSet::new(); + let mut generated_artifacts = Vec::with_capacity(manifest.artifacts.len()); + for artifact in &manifest.artifacts { + let relative_path = artifact + .path + .strip_prefix("generated/") .ok_or(PackageError::Verification)?; - if packaged != &artifact.content { + if relative_path.is_empty() + || !artifact_ids.insert(artifact.id.as_str()) + || !artifact_paths.insert(relative_path) + || artifact + .operation_identifier + .as_deref() + .is_some_and(|identifier| !operation_identifiers.contains(identifier)) + { return Err(PackageError::Verification); } - // Retain bytes read from the sealed package after reproducing them. - artifact.content.clone_from(packaged); + let file_entries = manifest + .files + .iter() + .filter(|entry| entry.path == artifact.path) + .collect::>(); + if file_entries.len() != 1 + || !file_entries[0].generated + || file_entries[0].media_type != artifact.media_type + || file_entries[0].visibility != artifact.visibility + || file_entries[0].sha256 != artifact.sha256 + { + return Err(PackageError::Verification); + } + let content = loaded + .get(&artifact.path) + .ok_or(PackageError::Verification)? + .clone(); + generated_artifacts.push(GeneratedArtifact { + id: artifact.id.clone(), + path: relative_path.to_owned(), + media_type: artifact.media_type.clone(), + visibility: artifact.visibility, + operation_identifier: artifact.operation_identifier.clone(), + representation_identifier: artifact.representation_identifier.clone(), + sha256: artifact.sha256.clone(), + content, + }); } + let loaded_generated_paths = loaded + .keys() + .filter_map(|path| path.strip_prefix("generated/")) + .collect::>(); + if artifact_paths != loaded_generated_paths + || !valid_operation_artifact_bindings( + &manifest.operation_artifact_bindings, + &operation_identifiers, + &artifact_paths, + ) + { + return Err(PackageError::Verification); + } + let artifacts = ArtifactSet { + contract_revision: registry.contract_revision.clone(), + artifacts: generated_artifacts, + operation_bindings: manifest.operation_artifact_bindings.clone(), + }; Ok(VerifiedPackage { manifest, contract, @@ -409,38 +603,48 @@ struct UnsignedManifest<'a> { source_schema_fingerprints: &'a BTreeMap, source_schemas: &'a BTreeMap, artifacts: &'a [PackageArtifact], + operation_artifact_bindings: &'a [OperationArtifactBindings], files: &'a [PackageFile], } +fn valid_operation_artifact_bindings( + bindings: &[OperationArtifactBindings], + operation_identifiers: &BTreeSet<&str>, + artifact_paths: &BTreeSet<&str>, +) -> bool { + let mut bound_operations = BTreeSet::new(); + for binding in bindings { + if !operation_identifiers.contains(binding.operation_identifier.as_str()) + || !bound_operations.insert(binding.operation_identifier.as_str()) + || [ + binding.vocabulary_path.as_str(), + binding.context_path.as_str(), + binding.representation_schema_path.as_str(), + binding.representation_shacl_path.as_str(), + binding.classification_path.as_str(), + binding.processing_path.as_str(), + ] + .iter() + .any(|path| !artifact_paths.contains(path)) + { + return false; + } + } + bound_operations == *operation_identifiers +} + fn capture_governed_closure( project_root: &Path, contract: &RegistryContract, review: Option<&CompiledClassificationReview>, ) -> Result>, PackageError> { - let mut references = BTreeSet::new(); - references.insert(contract.registry.identifier_lifecycle_policy_ref.as_str()); - references.insert(contract.classifications.provenance_ref.as_str()); + let mut references = referenced_governed_files(contract); if let Some(review) = review { references.insert(review.rationale_ref.as_str()); if let Some(generated) = &review.generated_identification { references.insert(generated.report_ref.as_str()); } } - for alignment in &contract.semantics.alignments { - references.insert(alignment.profile_ref.as_str()); - } - for resource in &contract.resources { - references.insert(resource.record_context.lifecycle_state.codelist.as_str()); - for (_, property) in resource.properties.iter() { - if let Some(codelist) = property.codelist.as_deref() { - references.insert(codelist); - } - } - for processing in &resource.processing_descriptions { - references.insert(processing.legal_basis_ref.as_str()); - references.insert(processing.dpv_profile_ref.as_str()); - } - } if references.len() > MAX_AUTHORED_FILES { return Err(PackageError::ClosureBound); } @@ -849,6 +1053,30 @@ mod tests { ) .expect("compiled Registry"); let artifacts = generate_artifacts(®istry).expect("artifacts"); + let mut mismatched_artifacts = artifacts.clone(); + mismatched_artifacts.contract_revision = "sha256:mismatched".into(); + assert!(matches!( + build_package( + &project, + &temporary.path().join("rejected-package"), + &contract, + ®istry, + &mismatched_artifacts, + ), + Err(PackageError::Verification) + )); + let mut mismatched_registry = registry.clone(); + mismatched_registry.registry_name = "Different Registry semantics".into(); + assert!(matches!( + build_package( + &project, + &temporary.path().join("rejected-compiled-registry"), + &contract, + &mismatched_registry, + &artifacts, + ), + Err(PackageError::Verification) + )); let output = temporary.path().join("package"); let manifest = build_package(&project, &output, &contract, ®istry, &artifacts) .expect("sealed package"); @@ -859,6 +1087,21 @@ mod tests { let verified = load_package(&resolved_output).expect("verified package"); assert_eq!(verified.manifest, manifest); assert_eq!(verified.registry, registry); + assert_eq!(verified.artifacts, artifacts); + assert!(manifest + .files + .iter() + .any(|file| file.path == COMPILED_REGISTRY_PATH)); + assert_eq!( + manifest.operation_artifact_bindings, + artifacts.operation_bindings + ); + + let compiled_path = output.join(COMPILED_REGISTRY_PATH); + let compiled_bytes = fs::read(&compiled_path).expect("compiled Registry bytes"); + fs::write(&compiled_path, b"{}").expect("tamper compiled Registry"); + assert!(load_package(&resolved_output).is_err()); + fs::write(&compiled_path, compiled_bytes).expect("restore compiled Registry"); let artifact = &manifest.artifacts[0]; fs::write(output.join(&artifact.path), b"tampered").expect("tamper fixture"); diff --git a/crates/registry-relay-v2/src/semantics.rs b/crates/registry-relay-v2/src/semantics.rs index 326b665f8..3ba3c2aa1 100644 --- a/crates/registry-relay-v2/src/semantics.rs +++ b/crates/registry-relay-v2/src/semantics.rs @@ -124,17 +124,9 @@ fn record_schema( schema_reference: &str, semantic_model_reference: &str, ) -> Value { - let lifecycle_values = registry - .codelists - .iter() - .find(|item| item.path == resource.record_context.lifecycle_state_codelist) - .map(|item| item.values.clone()) - .unwrap_or_default(); - let lifecycle_schema = if lifecycle_values.is_empty() { - json!({"type": "string", "minLength": 1}) - } else { - json!({"type": "string", "enum": lifecycle_values}) - }; + let lifecycle_values = + &require_codelist(registry, &resource.record_context.lifecycle_state_codelist).values; + let lifecycle_schema = json!({"type": "string", "enum": lifecycle_values}); let mut domain_properties = Map::new(); let mut domain_required = Vec::new(); for property in selected_properties(resource, selected) { @@ -207,6 +199,9 @@ fn shacl( selected: &[String], full: bool, ) -> String { + let lifecycle_values = + &require_codelist(registry, &resource.record_context.lifecycle_state_codelist).values; + let lifecycle_constraint = shacl_in(lifecycle_values); let mut output = format!( "@prefix sh: .\n@prefix xsd: .\n\n<{}shapes/{}> a sh:NodeShape ;\n sh:targetClass <{}> ;\n sh:closed true", registry.local_vocabulary, resource.id, resource.semantic_class @@ -236,27 +231,28 @@ fn shacl( ), ("recordedAt", "http://www.w3.org/2001/XMLSchema#dateTime"), ] { + let controlled_values = if path == "lifecycleState" { + lifecycle_constraint.as_str() + } else { + "" + }; output.push_str(&format!( - " ;\n sh:property [ sh:path ; sh:datatype <{datatype}> ; sh:minCount 1 ; sh:maxCount 1 ]" + " ;\n sh:property [ sh:path ; sh:datatype <{datatype}>{controlled_values} ; sh:minCount 1 ; sh:maxCount 1 ]" )); } for property in selected_properties(resource, selected) { - let controlled_values = property - .codelist - .as_deref() - .and_then(|path| registry.codelists.iter().find(|item| item.path == path)) - .map(|codelist| { - format!( - " ; sh:in ( {} )", - codelist - .values - .iter() - .map(|value| format!("\"{}\"", turtle_escape(value))) - .collect::>() - .join(" ") - ) - }) - .unwrap_or_default(); + let controlled_values = match property.data_type { + DataType::ControlledCode => { + let path = property.codelist.as_deref().unwrap_or_else(|| { + panic!( + "compiled semantics invariant: controlled property {} has no codelist", + property.name + ) + }); + shacl_in(&require_codelist(registry, path).values) + } + _ => String::new(), + }; output.push_str(&format!( " ;\n sh:property [ sh:path <{}> ; sh:datatype <{}>{} ; sh:minCount {} ; sh:maxCount 1 ]", property.semantic_iri, @@ -273,17 +269,14 @@ fn property_schema(registry: &CompiledRegistry, property: &CompiledProperty) -> match property.data_type { DataType::String => json!({"type": "string"}), DataType::ControlledCode => { - let values = property - .codelist - .as_deref() - .and_then(|path| registry.codelists.iter().find(|item| item.path == path)) - .map(|codelist| codelist.values.clone()) - .unwrap_or_default(); - if values.is_empty() { - json!({"type": "string", "x-registry-codelist": property.codelist}) - } else { - json!({"type": "string", "enum": values, "x-registry-codelist": property.codelist}) - } + let path = property.codelist.as_deref().unwrap_or_else(|| { + panic!( + "compiled semantics invariant: controlled property {} has no codelist", + property.name + ) + }); + let values = &require_codelist(registry, path).values; + json!({"type": "string", "enum": values, "x-registry-codelist": path}) } DataType::Boolean => json!({"type": "boolean"}), DataType::Integer => json!({"type": "integer"}), @@ -302,6 +295,30 @@ fn property_schema(registry: &CompiledRegistry, property: &CompiledProperty) -> } } +fn require_codelist<'a>( + registry: &'a CompiledRegistry, + path: &str, +) -> &'a crate::model::CompiledCodelist { + registry + .codelists + .iter() + .find(|item| item.path == path) + .unwrap_or_else(|| { + panic!("compiled semantics invariant: referenced codelist {path} is missing") + }) +} + +fn shacl_in(values: &[String]) -> String { + format!( + " ; sh:in ( {} )", + values + .iter() + .map(|value| format!("\"{}\"", turtle_escape(value))) + .collect::>() + .join(" ") + ) +} + fn turtle_escape(value: &str) -> String { value .replace('\\', "\\\\") @@ -344,6 +361,59 @@ mod tests { assert_eq!(context["@context"]["name"]["@nest"], "domainData"); } + #[test] + #[should_panic(expected = "referenced codelist state.yaml is missing")] + fn schema_generation_refuses_a_missing_lifecycle_codelist() { + let _ = full_record_schema(®istry(), &resource()); + } + + #[test] + #[should_panic(expected = "referenced codelist codes.yaml is missing")] + fn schema_generation_refuses_a_missing_property_codelist() { + let mut registry = registry(); + registry.codelists.push(codelist("state.yaml", &["ACTIVE"])); + let mut resource = resource(); + resource.properties[0].data_type = DataType::ControlledCode; + resource.properties[0].codelist = Some("codes.yaml".into()); + let _ = full_record_schema(®istry, &resource); + } + + #[test] + fn schemas_and_shacl_emit_every_compiled_codelist_constraint() { + let mut registry = registry(); + registry + .codelists + .push(codelist("state.yaml", &["ACTIVE", "RETIRED"])); + registry + .codelists + .push(codelist("codes.yaml", &["ONE", "TWO"])); + let mut resource = resource(); + resource.properties[0].data_type = DataType::ControlledCode; + resource.properties[0].codelist = Some("codes.yaml".into()); + + let schema = full_record_schema(®istry, &resource); + assert_eq!( + schema["properties"]["lifecycleState"]["enum"], + json!(["ACTIVE", "RETIRED"]) + ); + assert_eq!( + schema["properties"]["domainData"]["properties"]["name"]["enum"], + json!(["ONE", "TWO"]) + ); + let shacl = full_record_shacl(®istry, &resource); + assert!(shacl.contains("sh:in ( \"ACTIVE\" \"RETIRED\" )")); + assert!(shacl.contains("sh:in ( \"ONE\" \"TWO\" )")); + } + + fn codelist(path: &str, values: &[&str]) -> crate::model::CompiledCodelist { + crate::model::CompiledCodelist { + path: path.into(), + id: path.into(), + version: "1".into(), + values: values.iter().map(|value| (*value).into()).collect(), + } + } + fn resource() -> CompiledResource { use crate::contract::{Handling, ReviewStatus}; use crate::model::*; diff --git a/crates/registry-relay-v2/src/tooling.rs b/crates/registry-relay-v2/src/tooling.rs index e34f818d1..0c6b0d194 100644 --- a/crates/registry-relay-v2/src/tooling.rs +++ b/crates/registry-relay-v2/src/tooling.rs @@ -19,7 +19,8 @@ use thiserror::Error; use crate::artifacts::{generate_artifacts, ArtifactSet}; use crate::audit::RelayAudit; use crate::compiler::{ - classification_inventory_digest, compile_contract_with_governed_files, GovernedFileSet, + classification_inventory_digest, compile_contract_with_governed_files, + referenced_governed_files, GovernedFileSet, }; use crate::contract::{ClassificationReviewDocument, RegistryContract, RelayRuntime}; use crate::cursor::CursorKey; @@ -854,24 +855,10 @@ fn capture_governed_files( root: &Path, contract: &RegistryContract, ) -> Result { - let mut references = BTreeSet::new(); - references.insert(contract.registry.identifier_lifecycle_policy_ref.clone()); - references.insert(contract.classifications.provenance_ref.clone()); - for alignment in &contract.semantics.alignments { - references.insert(alignment.profile_ref.clone()); - } - for resource in &contract.resources { - references.insert(resource.record_context.lifecycle_state.codelist.clone()); - for (_, property) in resource.properties.iter() { - if let Some(codelist) = property.codelist.as_deref() { - references.insert(codelist.to_owned()); - } - } - for processing in &resource.processing_descriptions { - references.insert(processing.legal_basis_ref.clone()); - references.insert(processing.dpv_profile_ref.clone()); - } - } + let mut references = referenced_governed_files(contract) + .into_iter() + .map(str::to_owned) + .collect::>(); let canonical_root = root.canonicalize().map_err(|_| ToolingError::Read)?; let review_reference = contract.classifications.provenance_ref.as_str(); validate_relative(review_reference)?; diff --git a/crates/registry-relay-v2/tests/acceptance_http.rs b/crates/registry-relay-v2/tests/acceptance_http.rs index d2afaf62c..76af3b9bb 100644 --- a/crates/registry-relay-v2/tests/acceptance_http.rs +++ b/crates/registry-relay-v2/tests/acceptance_http.rs @@ -5,7 +5,7 @@ use std::convert::Infallible; use std::fs; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicUsize, Ordering}; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use axum::body::{to_bytes, Body}; @@ -141,6 +141,7 @@ struct ProjectHarness { struct ControlledAuditSink { fail_on_write: usize, writes: AtomicUsize, + records: Mutex>, } impl ControlledAuditSink { @@ -148,23 +149,32 @@ impl ControlledAuditSink { Self { fail_on_write, writes: AtomicUsize::new(0), + records: Mutex::new(Vec::new()), } } fn writes(&self) -> usize { self.writes.load(Ordering::SeqCst) } + + fn values(&self) -> Vec { + self.records.lock().expect("audit records lock").clone() + } } #[async_trait::async_trait] impl AuditSink for ControlledAuditSink { - async fn write(&self, _envelope: &AuditEnvelope) -> Result<(), AuditError> { + async fn write(&self, envelope: &AuditEnvelope) -> Result<(), AuditError> { let write = self.writes.fetch_add(1, Ordering::SeqCst) + 1; if write == self.fail_on_write { return Err(AuditError::Io(std::io::Error::other( "controlled audit failure", ))); } + self.records + .lock() + .expect("audit records lock") + .push(envelope.record.clone()); Ok(()) } @@ -288,6 +298,129 @@ async fn all_three_registry_http_journeys_use_the_real_router() { } } +#[tokio::test] +async fn business_list_with_a_late_malformed_row_fails_atomically() { + let sink = Arc::new(ControlledAuditSink::new(usize::MAX)); + let harness = ProjectHarness::open_with_audit( + "business-registry", + Some(Arc::clone(&sink) as Arc), + ) + .await; + let response = harness + .app + .oneshot( + Request::builder() + .uri("/v2/resources/registered-business/records?jurisdiction=EX-B&pageSize=4") + .body(Body::empty()) + .expect("business list request builds"), + ) + .await + .expect("router responds"); + let body = response_body(response, StatusCode::SERVICE_UNAVAILABLE).await; + assert_eq!(body["code"], "source.unavailable"); + + let response_wire = serde_json::to_string(&body).expect("problem response serializes"); + for hidden in [ + "BIZ-SYNTH-0002", + "BIZ-SYNTH-0004", + "BIZ-SYNTH-BAD1", + "Synthetic River Trading Ltd", + "Fixture Market Cooperative", + "Invalid Fixture Enterprise", + "not-a-date-time", + ] { + assert!( + !response_wire.contains(hidden), + "source values must not escape the failed page" + ); + } + + let records = sink.values(); + assert_eq!(records.len(), 2, "attempt and source-failed terminal audit"); + assert_eq!(records[0]["phase"], "attempt"); + assert!(records[0].get("outcome").is_none()); + assert_eq!(records[1]["phase"], "terminal"); + assert_eq!(records[1]["outcome"], "source-failed"); + assert_eq!(records[0]["operationId"], records[1]["operationId"]); + let audit_wire = serde_json::to_string(&records).expect("audit records serialize"); + for hidden in [ + "BIZ-SYNTH-0002", + "BIZ-SYNTH-0004", + "BIZ-SYNTH-BAD1", + "Synthetic River Trading Ltd", + "Fixture Market Cooperative", + "Invalid Fixture Enterprise", + "not-a-date-time", + ] { + assert!( + !audit_wire.contains(hidden), + "source values must not escape through audit" + ); + } +} + +#[tokio::test] +async fn malformed_disclosed_property_type_and_requiredness_fail_closed() { + let original = fs::read_to_string(project_root("business-registry").join("fixture.sql")) + .expect("business fixture reads"); + let valid_recorded_at = original.replacen( + "'not-a-date-time', 'Invalid Fixture Enterprise'", + "'2026-06-05T08:00:00Z', 'Invalid Fixture Enterprise'", + 1, + ); + assert_ne!(valid_recorded_at, original); + + let wrong_type = valid_recorded_at.replacen(") STRICT;", ");", 1).replacen( + "'Invalid Fixture Enterprise'", + "X'FF'", + 1, + ); + let missing_required = valid_recorded_at + .replacen("legal_name TEXT NOT NULL", "legal_name TEXT", 1) + .replacen("'Invalid Fixture Enterprise'", "NULL", 1); + + for (case, fixture_sql) in [ + ("wrong property type", wrong_type), + ("missing required property", missing_required), + ] { + let sink = Arc::new(ControlledAuditSink::new(usize::MAX)); + let harness = ProjectHarness::open_with_fixture_sql( + "business-registry", + fixture_sql, + Some(Arc::clone(&sink) as Arc), + true, + ) + .await; + let response = harness + .app + .oneshot( + Request::builder() + .uri("/v2/resources/registered-business/records/BIZ-SYNTH-BAD1") + .body(Body::empty()) + .expect("business read request builds"), + ) + .await + .expect("router responds"); + let body = response_body(response, StatusCode::SERVICE_UNAVAILABLE).await; + assert_eq!(body["code"], "source.unavailable", "{case}"); + let response_wire = serde_json::to_string(&body).expect("problem response serializes"); + assert!(!response_wire.contains("BIZ-SYNTH-BAD1"), "{case}"); + assert!( + !response_wire.contains("Invalid Fixture Enterprise"), + "{case}" + ); + + let records = sink.values(); + assert_eq!(records.len(), 2, "{case}: attempt and terminal audit"); + assert_eq!(records[0]["phase"], "attempt", "{case}"); + assert_eq!(records[1]["phase"], "terminal", "{case}"); + assert_eq!(records[1]["outcome"], "source-failed", "{case}"); + let audit_wire = serde_json::to_string(&records).expect("audit records serialize"); + assert!(!audit_wire.contains("BIZ-SYNTH-BAD1"), "{case}"); + assert!(!audit_wire.contains("Invalid Fixture Enterprise"), "{case}"); + } +} + async fn send_loopback_request( client: &reqwest::Client, address: std::net::SocketAddr, @@ -769,14 +902,23 @@ async fn operation_bound_metadata_is_no_store_and_links_only_visible_artifacts() async fn invalid_bearer_on_unknown_data_routes_is_audited_fail_closed() { let sink = Arc::new(ControlledAuditSink::new(usize::MAX)); let harness = ProjectHarness::open_with_audit( - "business-registry", + "civil-event", Some(Arc::clone(&sink) as Arc), ) .await; for (method, uri) in [ (Method::GET, "/v2/resources/unknown/records"), + (Method::GET, "/v2/resources/civil-event/records"), (Method::GET, "/v2/resources/unknown/records/record"), + ( + Method::GET, + "/v2/resources/civil-event/records/EVENT-SYNTH-0001", + ), (Method::POST, "/v2/resources/unknown/lookups/unknown"), + ( + Method::POST, + "/v2/resources/civil-event/lookups/verify-registration", + ), ] { let response = harness .app @@ -800,13 +942,28 @@ async fn invalid_bearer_on_unknown_data_routes_is_audited_fail_closed() { } assert_eq!( sink.writes(), - 3, + 6, "each invalid credential is refused in audit" ); + let records = sink.values(); + assert_eq!(records.len(), 6); + for record in &records { + assert_eq!(record["phase"], "refusal"); + assert_eq!(record["outcome"], "invalid-credential"); + assert_eq!(record["principalKind"], "unknown"); + assert!(record.get("resourceIdentifier").is_none()); + assert!(record.get("operationIdentifier").is_none()); + assert!(record.get("accessRuleRevision").is_none()); + assert_eq!(record["selectedProperties"], json!([])); + } + let audit_wire = serde_json::to_string(&records).expect("audit serializes"); + for hidden in ["EVENT-SYNTH-0001", "verify-registration", "malformed"] { + assert!(!audit_wire.contains(hidden)); + } let failing_sink = Arc::new(ControlledAuditSink::new(1)); let harness = ProjectHarness::open_with_audit( - "business-registry", + "civil-event", Some(Arc::clone(&failing_sink) as Arc), ) .await; @@ -815,7 +972,7 @@ async fn invalid_bearer_on_unknown_data_routes_is_audited_fail_closed() { .app .oneshot( Request::builder() - .uri("/v2/resources/unknown/records") + .uri("/v2/resources/civil-event/records/EVENT-SYNTH-0001") .header(AUTHORIZATION, "Bearer malformed") .body(Body::empty()) .expect("unknown request builds"), @@ -829,6 +986,159 @@ async fn invalid_bearer_on_unknown_data_routes_is_audited_fail_closed() { assert_eq!(failing_sink.writes(), 1); } +#[tokio::test] +async fn insufficient_scope_and_unknown_data_surfaces_are_indistinguishable() { + let sink = Arc::new(ControlledAuditSink::new(usize::MAX)); + let harness = ProjectHarness::open_with_audit( + "civil-event", + Some(Arc::clone(&sink) as Arc), + ) + .await; + let journey: Journey = serde_norway::from_slice( + &fs::read(project_root("civil-event").join("expected-http.yaml")).expect("journey reads"), + ) + .expect("journey parses"); + let read_fixture = journey + .authorizations + .get("civil-registrar-ex-a") + .expect("read fixture resolves"); + let lookup_fixture = journey + .authorizations + .get("civil-verifier-ex-a") + .expect("lookup fixture resolves"); + let read_token = harness.token("civil-registrar-ex-a", read_fixture); + let lookup_token = harness.token("civil-verifier-ex-a", lookup_fixture); + + for (token, method, known, unknown) in [ + ( + lookup_token.as_str(), + Method::GET, + "/v2/resources/civil-event/records/EVENT-SYNTH-0001", + "/v2/resources/unknown/records/EVENT-SYNTH-0001", + ), + ( + read_token.as_str(), + Method::POST, + "/v2/resources/civil-event/lookups/verify-registration", + "/v2/resources/civil-event/lookups/unknown", + ), + ( + lookup_token.as_str(), + Method::GET, + "/v2/resources/civil-event/records", + "/v2/resources/unknown/records", + ), + ] { + let mut normalized = None; + for uri in [known, unknown] { + let response = harness + .app + .clone() + .oneshot( + Request::builder() + .method(method.clone()) + .uri(uri) + .header(AUTHORIZATION, format!("Bearer {token}")) + .body(Body::empty()) + .expect("data request builds"), + ) + .await + .expect("router responds"); + let mut body = response_body(response, StatusCode::NOT_FOUND).await; + assert_eq!(body["code"], "resource.not_found"); + body.as_object_mut() + .expect("problem object") + .remove("traceId"); + if let Some(expected) = &normalized { + assert_eq!(&body, expected); + } else { + normalized = Some(body); + } + } + } + + let padding = "x".repeat(20_000); + let oversized_known = format!("/v2/resources/civil-event/records?padding={padding}"); + let oversized_unknown = format!("/v2/resources/unknown/records?padding={padding}"); + let mut normalized = None; + for uri in [&oversized_known, &oversized_unknown] { + let response = harness + .app + .clone() + .oneshot( + Request::builder() + .uri(uri) + .header(AUTHORIZATION, format!("Bearer {lookup_token}")) + .body(Body::empty()) + .expect("oversized list request builds"), + ) + .await + .expect("router responds"); + let mut body = response_body(response, StatusCode::NOT_FOUND).await; + assert_eq!(body["code"], "resource.not_found"); + body.as_object_mut() + .expect("problem object") + .remove("traceId"); + if let Some(expected) = &normalized { + assert_eq!(&body, expected); + } else { + normalized = Some(body); + } + } + + let records = sink.values(); + assert_eq!(records.len(), 8); + for record in &records { + assert_eq!(record["phase"], "refusal"); + assert_eq!(record["outcome"], "not-found"); + assert_eq!(record["principalKind"], "authenticated"); + assert!(record.get("resourceIdentifier").is_none()); + assert!(record.get("operationIdentifier").is_none()); + assert!(record.get("accessRuleRevision").is_none()); + assert_eq!(record["selectedProperties"], json!([])); + } + let audit_wire = serde_json::to_string(&records).expect("audit serializes"); + for hidden in ["EVENT-SYNTH-0001", "verify-registration"] { + assert!(!audit_wire.contains(hidden)); + } +} + +#[tokio::test] +async fn list_uri_refusal_uses_the_resolved_access_context() { + let sink = Arc::new(ControlledAuditSink::new(usize::MAX)); + let harness = ProjectHarness::open_with_audit( + "business-registry", + Some(Arc::clone(&sink) as Arc), + ) + .await; + let padding = "x".repeat(20_000); + let response = harness + .app + .oneshot( + Request::builder() + .uri(format!( + "/v2/resources/registered-business/records?padding={padding}" + )) + .body(Body::empty()) + .expect("oversized business list request builds"), + ) + .await + .expect("router responds"); + assert_problem_code(response, StatusCode::URI_TOO_LONG, "internal.uri_too_long").await; + + let records = sink.values(); + assert_eq!(records.len(), 1); + assert_eq!(records[0]["phase"], "refusal"); + assert_eq!(records[0]["outcome"], "invalid-request"); + assert_eq!(records[0]["principalKind"], "anonymous"); + assert_eq!(records[0]["resourceIdentifier"], "registered-business"); + assert_eq!( + records[0]["operationIdentifier"], + "registered-business.list" + ); + assert_eq!(records[0]["selectedProperties"], json!([])); +} + #[tokio::test] async fn lookup_body_collection_obeys_the_request_deadline() { let harness = ProjectHarness::open("social-assistance").await; @@ -1113,21 +1423,26 @@ impl ProjectHarness { async fn open_with_audit(project: &str, sink: Option>) -> Self { let root = project_root(project); - let contract = RegistryContract::parse_yaml( - &fs::read_to_string(root.join("registry.yaml")).expect("contract reads"), - ) - .expect("contract parses"); + let fixture_sql = fs::read_to_string(root.join("fixture.sql")).expect("fixture SQL reads"); + Self::open_with_fixture_sql(project, fixture_sql, sink, false).await + } + + async fn open_with_fixture_sql( + project: &str, + fixture_sql: String, + sink: Option>, + accept_fixture_fingerprint: bool, + ) -> Self { + let root = project_root(project); + let contract_yaml = fs::read_to_string(root.join("registry.yaml")).expect("contract reads"); + let mut contract = RegistryContract::parse_yaml(&contract_yaml).expect("contract parses"); let runtime = RelayRuntime::parse_yaml( &fs::read_to_string(root.join("runtime.yaml")).expect("runtime reads"), ) .expect("runtime parses"); let temp = tempfile::tempdir().expect("temporary project creates"); let database = temp.path().join("fixture.sqlite"); - materialize_fixture( - &database, - &fs::read_to_string(root.join("fixture.sql")).expect("fixture SQL reads"), - ) - .expect("fixture materializes"); + materialize_fixture(&database, &fixture_sql).expect("fixture materializes"); let captured = CapturedSnapshot::capture(&database).expect("fixture captures"); let catalog = inspect_schema( @@ -1147,6 +1462,19 @@ impl ProjectHarness { .next() .expect("one source") .to_owned(); + if accept_fixture_fingerprint { + let governed_fingerprint = contract + .sources + .get(&source_id) + .expect("fixture source resolves") + .expected_schema_fingerprint + .clone(); + let governed_yaml = + contract_yaml.replacen(&governed_fingerprint, &observed_fingerprint, 1); + assert_ne!(governed_yaml, contract_yaml, "source fingerprint rewrites"); + contract = RegistryContract::parse_yaml(&governed_yaml) + .expect("fixture-governed contract parses"); + } let observed = vec![ObservedSourceSchema { source: source_id.clone(), fingerprint: catalog.fingerprint, @@ -1543,6 +1871,13 @@ fn governed_files(root: &Path, contract: &RegistryContract) -> GovernedFileSet { paths.insert(path.clone()); } } + for lookup in &resource.operations.lookups { + for (_, selector) in lookup.request_body.selectors.iter() { + if let Some(path) = &selector.codelist { + paths.insert(path.clone()); + } + } + } for processing in &resource.processing_descriptions { paths.insert(processing.legal_basis_ref.clone()); paths.insert(processing.dpv_profile_ref.clone()); diff --git a/crates/registry-relay-v2/tests/multi_resource_isolation.rs b/crates/registry-relay-v2/tests/multi_resource_isolation.rs index 8bf31565e..bfd861b1a 100644 --- a/crates/registry-relay-v2/tests/multi_resource_isolation.rs +++ b/crates/registry-relay-v2/tests/multi_resource_isolation.rs @@ -571,8 +571,8 @@ async fn real_router_keeps_related_public_and_protected_resources_isolated() { "00000000000000000000000000000003", ) .await, - StatusCode::FORBIDDEN, - "consultation.denied", + StatusCode::NOT_FOUND, + "resource.not_found", ); assert_problem( send( From fa6c24c2bfd9ff107552f25a07afc6c0df1acd5b Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 10 Aug 2026 13:23:27 +0700 Subject: [PATCH 07/24] fix(relay): close Relay V2 acceptance contracts Signed-off-by: Jeremi Joslin --- products/relay-v2/CONCEPT.md | 58 +++++---- products/relay-v2/CONFIGURATION-EXAMPLES.md | 7 +- products/relay-v2/DEFINITION-OF-DONE.md | 27 ++--- products/relay-v2/IMPLEMENTATION.md | 47 +++++--- products/relay-v2/README.md | 1 - .../codelists/civil-event-selector-types.yaml | 4 + .../acceptance/civil-event/expected-http.yaml | 14 +-- .../acceptance/civil-event/registry.yaml | 2 +- .../social-assistance/expected-http.yaml | 11 +- .../contracts/acceptance-scenario-matrix.yaml | 10 +- .../contracts/generated-baselines.yaml | 6 + .../contracts/security-invariant-matrix.yaml | 86 ++++++++++---- .../scripts/check-exposure-inventory.sh | 5 - .../scripts/check-source-neutrality.sh | 3 +- .../relay-v2/scripts/test_adopter_workflow.py | 110 ++++++++++++------ .../relay-v2/scripts/test_validate_product.py | 89 +++++++++++--- products/relay-v2/scripts/validate_product.py | 91 +++++++++++++-- 17 files changed, 397 insertions(+), 174 deletions(-) create mode 100644 products/relay-v2/acceptance/civil-event/codelists/civil-event-selector-types.yaml delete mode 100755 products/relay-v2/scripts/check-exposure-inventory.sh diff --git a/products/relay-v2/CONCEPT.md b/products/relay-v2/CONCEPT.md index 80fde284c..99c0e5d29 100644 --- a/products/relay-v2/CONCEPT.md +++ b/products/relay-v2/CONCEPT.md @@ -21,7 +21,7 @@ The concise promise is: > Give Relay an authoritative SQLite database and a registry contract. Relay produces a protected registry API whose meaning, disclosure, authorization, provenance, and documentation agree by construction. -Relay V2 follows the method that made Evidence tractable: a narrow product boundary, startup-compiled trusted artifacts, deterministic runtime behavior, explicit security invariants, coequal acceptance fixtures, and generated contracts checked for drift. +Relay V2 follows the method that made Evidence tractable: a narrow product boundary, sealed compiler-produced artifacts, deterministic runtime behavior, explicit security invariants, coequal acceptance fixtures, and generated contracts checked for drift. ## What the product accepts and produces @@ -85,8 +85,9 @@ technical operator. A Relay resource is a governed Record type or collection within that Registry, not a table and not a second Registry. It has a stable identity, semantic class, -identifier strategy, declared properties, a reviewed source view, an enumeration -posture, a disclosure profile, and an access rule. +identifier strategy, declared properties, a reviewed source view, compiled +operations, disclosure profiles, and access rules. Its enumeration posture is +derived from those compiled operations rather than authored separately. The source schema and public schema are deliberately separate. One table may support several resources, several tables may feed one reviewed view, and unconfigured database objects remain invisible. @@ -359,12 +360,13 @@ naturally idempotent bounded consultation and returns at most one Record. Lists use `pageSize`, `cursor`, and the envelope `{items, pageInfo: {nextCursor}, meta}`. `nextCursor` is nullable. Ordering is -contract-defined with the Record identifier as a unique tie-breaker. The opaque -authenticated cursor binds the contract and source revisions, operation, -selected representation and disclosure profile, transform inventory, filters, +contract-defined with the Record identifier as a unique tie-breaker. The +client-opaque authenticated-encrypted cursor binds the contract and source +revisions, operation, selected representation and disclosure profile, filters, order, selected fields, authorization context, and expiry. Every page is -reauthorized. Callers cannot choose an order or replay a cursor across -representations. +reauthorized. Encryption prevents its filter and keyset-order state from +bypassing field minimization. Callers treat it as an uninterpreted continuation +token and cannot choose an order or replay a cursor across representations. Single-record reads and resolved lookups use `{data, meta}`. `data` contains the Registry Core context and `domainData`. `fields` is a documented Relay @@ -410,20 +412,27 @@ does not adopt draft GovStack BB codes or problem namespaces. V2 accepts W3C Trace Context; every Problem `traceId` is the effective valid or server-created trace ID as 32 lowercase hexadecimal characters. Caller-supplied `tracestate` is never propagated because Relay cannot establish that vendor state is -value-free. Unknown, -hidden, ambiguous, and unsafe lookup outcomes use the same `404` status, -problem code, fixed detail, schema, cache and security headers, differing only -in independently generated trace correlation. Problems never echo selectors, -identifiers, source values, SQL, paths, tokens, or policy internals. +value-free. Unknown, hidden, and ambiguous lookup outcomes use the same `404` +status, problem code, fixed detail, schema, cache and security headers, +differing only in independently generated trace correlation. A selected +malformed source Record fails closed as `503 source.unavailable`. Problems +never echo selectors, identifiers, source values, SQL, paths, tokens, or policy +internals. -### Explicit enumeration posture +### Derived enumeration posture -Every resource declares one orthogonal enumeration posture: +Every resource has one enumeration posture derived from its compiled list +operation: - `public` requires a public list operation; - `protected` requires a scope-protected list operation; - `none` forbids a list operation. +The compiler validates these operation combinations. Discovery reports the +posture visible to the current caller, so a protected list appears as `none` to +a caller who cannot see that capability. This caller-relative view does not +change the resource's compiled maximum or create an additional policy input. + Read and named exact-lookup operations are declared independently with their own access rules and disclosure profiles. This lets a resource expose protected read plus exact lookup without pretending it is an enumerable collection. @@ -492,9 +501,18 @@ Operator-only artifacts stay in the sealed package and CLI and are never mounted. Semantic transparency must not accidentally publish sensitive schema, selector, classification, or processing details. -Exact lookup returns a stable unresolved outcome for no match, ambiguous match, a record hidden by policy, or a source record that cannot safely be disclosed. Syntactically invalid requests receive a bounded public error without echoing values. Relay never skips, coerces, or partially releases an invalid selected source record to keep a response successful. +Exact lookup returns a stable unresolved outcome for no match, ambiguous match, +or a Record hidden by policy. A selected malformed source Record fails as +`source.unavailable`. Syntactically invalid requests receive a bounded public +error without echoing values. Relay never skips, coerces, or partially releases +an invalid selected source Record to keep a response successful. -A complete contract revision is compiled, validated, and activated atomically at startup. Relay never mixes revisions, falls back to a previous interpretation silently, or hot-reloads a partially valid contract. +`relayctl package` compiles and validates a complete contract revision and seals +the compiled Registry plus every artifact digest. Startup verifies that closed +package, recompiles the captured inputs to require exact equality with the +packaged runtime plan, and activates the packaged artifacts without regenerating +them. Relay never mixes revisions, falls back to a previous interpretation +silently, or hot-reloads a partially valid contract. ### SQLite source profiles @@ -657,7 +675,7 @@ Relay keeps its product semantics: Other Evidence work should be reused as method before it is reused as code: -- atomic startup capture and compilation; +- atomic sealed-package verification and activation; - closed artifact sets and deterministic revisions; - value-free adopter diagnostics; - fixed public problem classes; @@ -679,8 +697,8 @@ The first coherent Relay V2 release should contain: 4. generated Consultation capability discovery and a concise Digital Registries alignment note; 5. generated local semantics, JSON-LD, JSON Schema, SHACL, full packaged OpenAPI, and safe public OpenAPI; 6. property classification with provenance and the `public`, `internal`, `confidential`, and `restricted` handling levels; -7. explicit public, protected, or absent enumeration with independently compiled list, read, and named-lookup operations; -8. `pageSize` and opaque-cursor lists, direct predefined equality filters, and safe caller selection of fewer properties than the operation profile; +7. derived public, protected, or absent enumeration with independently compiled list, read, and named-lookup operations; +8. `pageSize` and client-opaque integrity-protected cursor lists, direct predefined equality filters, and safe caller selection of fewer properties than the operation profile; 9. strict OAuth JWT access-token verification, operation scopes, trusted purpose, optional authority row binding, and an optional conforming Mint issuer; 10. snapshot and live read-only SQLite profiles, including useful unversioned live read and lookup deployments; 11. deterministic disclosure plans, bounded queries, stable `404` lookup outcomes, atomic activation, and Registry Stack problems; diff --git a/products/relay-v2/CONFIGURATION-EXAMPLES.md b/products/relay-v2/CONFIGURATION-EXAMPLES.md index 88c26ed85..35df3f7a5 100644 --- a/products/relay-v2/CONFIGURATION-EXAMPLES.md +++ b/products/relay-v2/CONFIGURATION-EXAMPLES.md @@ -15,7 +15,7 @@ portability tooling, not a Version one runtime input. The intended boundaries are firmer than the syntax: -- `RegistryContract` is governed, versioned, compiled at startup, and cannot be overridden by runtime configuration; +- `RegistryContract` is governed, versioned, compiled and sealed by `relayctl package`, verified at startup, and cannot be overridden by runtime configuration; - `RelayRuntime` binds deployment-local paths, listeners, token issuers, and audit storage without changing resources, operations, disclosure, or semantics; - SQLite views and columns are source bindings, while resources and properties are the public model; - one contract describes one Registry; each resource is a Record type within it; @@ -274,7 +274,8 @@ What this example must prove: - selectors and the hidden `service_area_code` never become public properties; - selecting `enrolmentStatus,validThrough` returns less than the authorized default without changing authorization; - a useful local vocabulary, JSON-LD context, JSON Schema, and SHACL starter are generated without any external vocabulary mapping; -- no match, ambiguity, a hidden row, and an invalid source record return the same `404` problem except for trace correlation; +- no match, ambiguity, and a hidden row return the same `404` problem except for trace + correlation, while an invalid selected source Record fails closed as `503 source.unavailable`; - mandatory Registry Core context remains present and capability discovery derives only constrained `consultation.search`. ## Example 2: public business registry @@ -466,7 +467,7 @@ What this example must prove: - public means explicitly compiled public access, not absence of a global authentication setting; - filters appear as direct camelCase parameters such as `status=ACTIVE`, and accept only the named property, datatype, codelist, and exact-equality operator; -- pagination uses `pageSize`, an opaque `cursor`, and `items` with nullable `pageInfo.nextCursor`, while publisher-declared stable ordering prevents arbitrary sorting; +- pagination uses `pageSize`, a client-opaque authenticated-encrypted `cursor`, and `items` with nullable `pageInfo.nextCursor`, while publisher-declared stable ordering prevents arbitrary sorting; encryption prevents filter and keyset-order values from bypassing field minimization; - a requested subset such as `registrationNumber,legalName,registrationStatus` has its own correct ETag and JSON-LD representation; - the captured snapshot digest and schema fingerprint make responses reproducible and strongly cacheable by revision; - capability discovery derives `consultation.list` and `consultation.retrieve` and no other family pattern. diff --git a/products/relay-v2/DEFINITION-OF-DONE.md b/products/relay-v2/DEFINITION-OF-DONE.md index d01f221e3..73e4344cf 100644 --- a/products/relay-v2/DEFINITION-OF-DONE.md +++ b/products/relay-v2/DEFINITION-OF-DONE.md @@ -41,26 +41,26 @@ prove in-process resource isolation without adding a fourth deployment project. | Registry identity | One contract and process describes exactly one Registry with a globally stable `registryIdentifier`, name, Registry Authority, optional operator, authoritative scope, base URI, and pinned alignment targets. Resources are Record types within that Registry. Authority, controller, publisher, and operator remain distinct roles even when one institution fills several. | | Registry Core | Every returned Record contains non-selectable `registryIdentifier`, `recordIdentifier`, `revisionIdentifier`, `lifecycleState`, `schemaReference`, `semanticModelReference`, `authorityIdentifier`, `recordedAt`, and selected `domainData`. Record identifier, revision, lifecycle, and recorded time are source-bound; recorded time is never Relay observation time. The Registry and Record identifier pair is stable, and the contract names the institution's identifier-lifecycle policy without claiming Relay can prove non-reassignment from one current database. | | Family capabilities | Each compiled operation carries a derived family and pattern: read is `consultation.retrieve`, list is `consultation.list`, and named exact lookup is constrained `consultation.search`. Capability discovery is generated from operations rather than separately authored. Relay makes no claim for Record Match or the Provisioning, Evidence, Write, Notification, Aggregate Data, Access Transparency, or Identity Federation families. | -| Compilation and activation | Relay compiles and validates the complete contract before listening, produces one deterministic contract revision, and activates it atomically. Incomplete semantics, unclassified published properties, invalid source bindings, schema drift, conflicting operations, or unsafe access rules prevent readiness. There is no partial activation, runtime merge, silent fallback, or hot reload. | +| Compilation and activation | The shared compiler validates the complete contract before packaging, produces one deterministic compiled Registry and contract revision, and seals them with every artifact digest. Before listening, Relay verifies the closed package and recompiles the captured contract, observed schemas, and governed files solely to prove that the packaged runtime plan is identical. It does not regenerate artifacts. Incomplete semantics, unclassified published properties, invalid source bindings, schema drift, conflicting operations, unsafe access rules, or package inconsistency prevent packaging or readiness. There is no partial activation, runtime merge, silent fallback, or hot reload. | | Domain neutrality | Production Rust, public configuration schemas, routes, CLI options, and generated generic contracts contain no social-registry, business-registry, CRVS, birth, death, household, benefit, company, or acceptance-fixture-specific type, branch, feature, or operation. Such terms appear only in examples, fixtures, and explanatory documentation. | | SQLite source boundary | Snapshot and live read-only profiles use one hardened SQLite boundary with OS and engine read-only enforcement, defensive authorizer rules, bound values, consistent per-request transactions, schema fingerprints, and step, time, row, cell, response, queue, and concurrency limits. Writes, schema changes, control statements, attachment, extension loading, and unreviewed SQL are impossible through the public contract. | | Snapshot profile | A deployment-bound snapshot is captured outside the contract package, bound to its exact file identity and digest, refuses unsafe sidecars or path replacement, opens immutably, and reports a truthful source revision. Identical governed package and snapshot inputs produce identical revisions and generated artifacts. Snapshot mode is supported but not required for a deployment. | | Live profile | A live database is opened read-only while a separately trusted publisher may update it. Each response uses one consistent read transaction and verifies the expected schema fingerprint. Version one live sources compile read and exact lookup only, always return `sourceRevision: {profile: live, status: unversioned, value: null}`, use `no-store`, and emit no ETag. Publisher-owned revisions, live pagination, and live caching are deferred. | | Closed operation model | Resources compile only declared list, identifier-read, and named exact-lookup operations. A list's operation-owned query shape determines whether enumeration is permitted; absence of list means no enumeration. Collection filters are direct publisher-defined camelCase query parameters, typed, non-personal, and exact-equality only. Any non-empty subset of declared filters is valid, and the contract separately permits or forbids unfiltered access. `pageSize`, `cursor`, `fields`, and `representation` are reserved. Lookups have complete bounded body inputs and exactly zero or one disclosed result. Callers cannot add SQL, source columns, joins, expressions, operators, paths, projection expressions, sort orders, or page traversal. | -| Representation selection and requester minimization | Every operation has a finite ordered `representations` map and exactly one explicit `defaultRepresentation`. If any representation is public, the default must be public so omission is truthful in public OpenAPI and anonymous clients never select a hidden protected default. The direct `representation` parameter accepts exactly one non-empty compiled identifier; absence selects the default. Relay authenticates a supplied bearer before public selection and authorizes only the selected representation. Malformed, repeated, or empty selection is `400 request.representation_invalid`; unknown or unavailable selection is `404 representation.not_found`; denial, including purpose or row binding, is `403 consultation.denied`; none falls back to another representation or reaches source access. `fields` may select only a non-empty duplicate-free subset of that selected profile. Registry Core remains present. Unknown, internal, source-column, cross-profile, or malformed selections fail before source access and cannot change predicates, bindings, transforms, validation, authorization, effective handling, audit, quota, metadata, or cache posture. | -| HTTP and pagination | Business routes are under `/v2`; `/health`, `/ready`, and `/openapi.json` are unversioned. `GET /v2` publishes safe first-class Registry service metadata and visible derived capabilities. Lists accept bounded `pageSize` and opaque `cursor` and return `{items, pageInfo: {nextCursor}, meta}` with nullable `nextCursor`. Cursor integrity binds revisions, operation, selected representation and disclosure profile, transform inventory, filters, fixed order, field set, authorization context, and expiry; each page is reauthorized. Single reads and resolved lookups return `{data, meta}`. No caller sorting exists. | -| Query, transformation, and serialization | Relay may read the complete fixed reviewed processing projection so it can validate the authoritative Record before disclosure. Unrequested and hidden columns are never serialized. Only compiled `partial-string` and `date-precision` transforms run: `partial-string` uses the fixed Relay marker `***` and bounded Unicode-scalar prefix or suffix reveal, while `date-precision` produces only `year` or `year-month` from canonical date/date-time input. A transformed property has its own name, term, datatype, and classification. A partial-string input no longer than its reveal bound succeeds as `***` without revealing a source character. Required null, wrong type, noncanonical value, or transform input/output length failure releases nothing: read and list return `503 source.unavailable`, while exact lookup conceals an unsafe selected row as `404 consultation.unresolved`. Ordinary JSON and JSON-LD disclose the same Registry Core identity and domain values with deterministic property order. JSON-LD adds the generated context and a derived `@id` without replacing `recordIdentifier`. Cacheable responses require public selected representation, public processing handling, and a snapshot; their strong ETag binds exact selected-profile bytes, `Vary: Accept, Authorization`, `If-None-Match`, and `304`. Other responses are `no-store` and have no ETag. | +| Representation selection and requester minimization | Every operation has a finite ordered `representations` map and exactly one explicit `defaultRepresentation`. If any representation is public, the default must be public so omission is truthful in public OpenAPI and anonymous clients never select a hidden protected default. The direct `representation` parameter accepts exactly one non-empty compiled identifier; absence selects the default. Relay authenticates a supplied bearer before selection and authorizes only the selected representation. Malformed, repeated, or empty selection is `400 request.representation_invalid`; an unavailable selection is concealed from a principal without its operation scope; purpose or row-binding denial after scope selection is `403 consultation.denied`. No request falls back to another representation or reaches source access after denial. `fields` may select only a non-empty duplicate-free subset of that selected profile. Registry Core remains present. Unknown, internal, source-column, cross-profile, or malformed selections fail before source access and cannot change predicates, bindings, validation, authorization, effective handling, audit, quota, metadata, or cache posture. | +| HTTP and pagination | Business routes are under `/v2`; `/health`, `/ready`, and `/openapi.json` are unversioned. `GET /v2` publishes safe first-class Registry service metadata and visible derived capabilities. Lists accept bounded `pageSize` and a client-opaque, authenticated-encrypted `cursor` and return `{items, pageInfo: {nextCursor}, meta}` with nullable `nextCursor`. Cursor confidentiality prevents filters and keyset-order values from bypassing field minimization; integrity binds revisions, operation, selected representation and disclosure profile, filters, fixed order, field set, authorization context, and expiry; each page is reauthorized. Single reads and resolved lookups return `{data, meta}`. No caller sorting exists. | +| Query and serialization minimization | Relay may read the complete fixed reviewed projection so it can validate the authoritative Record before disclosure. Unrequested and hidden columns are never serialized. Required null, wrong type, noncanonical value, or size failure releases nothing and returns value-free `503 source.unavailable` for read, list, and lookup. Ordinary JSON and JSON-LD disclose the same Registry Core identity and selected domain values with deterministic property order. JSON-LD adds the generated context and a derived `@id` without replacing `recordIdentifier`. Cacheable responses require a public selected representation, public processing handling, and a snapshot; their strong ETag binds exact selected-profile bytes, `Vary: Accept, Authorization`, `If-None-Match`, and `304`. Other responses are `no-store` and have no ETag. | | Semantic contract | Every resource and property has a stable local semantic identity, datatype, cardinality, label, and description. `relayctl` can generate reviewed starter semantics, JSON-LD context, permitted-representation JSON Schema and SHACL, full-record validation schema and SHACL, and codelist scaffolding without requiring prior semantic-web expertise. The representation artifacts require Registry Core and validate selectable domain properties only when present; full-record artifacts retain source requiredness. `semanticModelReference` resolves to the generated vocabulary/model and the context is linked separately. Generated suggestions are visibly non-authoritative until accepted. | | External semantic alignment | Optional mappings to SEMIC, PublicSchema, schema.org, or another profile are curated, relation-qualified, versioned, and digest-pinned. Relay fetches no vocabulary and performs no inference at request time. Mapping changes appear in change-impact reports and cannot silently widen disclosure. | -| Identification, classification, and review | The existing axes remain exact: `semanticTerm`, `privacy`, `institutional`, `handling`, `status`, and `provenanceRef`. `relayctl` identification is offline, schema-only, deterministic, explainable, and value-free; it reads no source values and no candidate self-approves. Every property and every processed source-view column has an effective reviewed classification. `sourceColumnClassifications` is explicit and complete for every transformed or multiply-bound column. The reviewed `ClassificationReview` at `classifications.provenanceRef` digest-binds Registry identity and the classification inventory; generated review additionally binds its accepted report and exact rule pack. Missing, suggested, uncertain, stale, or tampered review fails production compilation. Manual and imported review remain first-class. | -| Processing versus disclosure handling | Resource defaults reduce repetition; compilation expands defaults and explicit overrides before validation. Processing handling is the maximum across Registry Core, direct output, transform input, selector, filter, order, and row-binding source columns. Disclosure handling is the maximum across serializable properties for the selected representation. Authentication, audit, cache, source controls, and public eligibility use processing handling. A transform may disclose a lower reviewed output but cannot weaken raw-source processing controls. A public representation may not process a non-public raw column: public masked publication requires a reviewed pre-derived public view column. Handling is one of ordered `public`, `internal`, `confidential`, or `restricted`; non-public processing requires authentication, scope, `no-store`, and durable value-free audit, and restricted data cannot be listed. Purpose and row binding remain explicit access constraints. | -| Authentication and issuers | Relay acts as an OAuth 2.0 JWT resource server for protected operations and Version one configures exactly one issuer per Registry deployment. Relay strictly verifies issuer, audience, token type, algorithm, key, time, client or subject, token identifier, and scope claims. Missing or invalid credentials return safe `401` responses; insufficient scope returns `403`. Anonymous access exists only on operations explicitly compiled as public. | +| Identification, classification, and review | The existing axes remain exact: `semanticTerm`, `privacy`, `institutional`, `handling`, `status`, and `provenanceRef`. `relayctl` identification is offline, schema-only, deterministic, explainable, and value-free; it reads no source values and no candidate self-approves. Every property and every processed source-view column has an effective reviewed classification. `sourceColumnClassifications` is explicit and complete for every multiply-bound column. The reviewed `ClassificationReview` at `classifications.provenanceRef` digest-binds Registry identity and the classification inventory; generated review additionally binds its accepted report and exact rule pack. Missing, suggested, uncertain, stale, or tampered review fails production compilation. Manual and imported review remain first-class. | +| Processing versus disclosure handling | Resource defaults reduce repetition; compilation expands defaults and explicit overrides before validation. Processing handling is the maximum across Registry Core, direct output, selector, filter, order, and row-binding source columns. Disclosure handling is the maximum across serializable properties for the selected representation. Authentication, audit, cache, source controls, and public eligibility use processing handling. A public representation may not process a non-public raw column; publishers create a reviewed pre-derived public view column when that is appropriate. Handling is one of ordered `public`, `internal`, `confidential`, or `restricted`; non-public processing requires authentication, scope, `no-store`, and durable value-free audit, and restricted data cannot be listed. Purpose and row binding remain explicit access constraints. | +| Authentication and issuers | Relay acts as an OAuth 2.0 JWT resource server for protected operations and Version one configures exactly one issuer per Registry deployment. Relay strictly verifies issuer, audience, token type, algorithm, key, time, client or subject, token identifier, and scope claims. Missing or invalid credentials return safe registry-wide `401` responses. A valid principal lacking the operation scope receives the same `404 resource.not_found` as an unknown resource or operation; after the scope selects the operation, insufficient purpose or authority returns `403 consultation.denied`. Anonymous access exists only on operations explicitly compiled as public. | | Operation authorization | List, read, and named lookup use distinct registered scopes that are unique across the Registry contract. Trusted purpose and authority-to-row binding are optional compiled constraints and can come only from the resolved principal or a direct verified scalar claim. Caller filters or headers never create authority. A lookup-only client cannot enumerate or perform identifier reads, even when another client can use those operations on the same deployment. | | Optional Mint pairing | Relay accepts a conforming token from an external authorization server without Mint. A Mint deployment may be paired when it emits the same Relay audience, operation scopes, and optional authority claims from server-side grants. Relay has no Mint runtime dependency or Mint-specific authentication branch. Mint changes and a Mint integration journey do not block the core Relay V1 acceptance path. | -| Lookup containment | Sensitive selectors use a bounded request body, are bound rather than rendered, and never appear in URLs, errors, logs, metrics, traces, audit, or responses. No match, ambiguity, policy-hidden record, unknown or protected identifier, and unsafe source record share one `404` outcome with the same Registry Stack problem type, code, detail, schema, and headers. Only independently generated trace correlation may differ. Invalid syntax is a value-free bounded request error. Rate and concurrency limits make consultation abuse observable and bounded. | +| Lookup containment | Sensitive selectors use a bounded request body, are bound rather than rendered, and never appear in URLs, errors, logs, metrics, traces, audit, or responses. No match, ambiguity, policy-hidden record, and unknown or protected identifier share one `404` outcome with the same Registry Stack problem type, code, detail, schema, and headers. Only independently generated trace correlation may differ. An invalid selected source row is a value-free `503 source.unavailable`; invalid request syntax is a value-free bounded request error. Rate and concurrency limits make consultation abuse observable and bounded. | | Validation and failure | Every selected row is schema-validated before release. Relay never skips, coerces, truncates, or partially releases an invalid row to preserve success. Errors use the fixed Registry Stack status/code catalog and derived type URIs plus `traceId`, exactly the 32-lowercase-hex trace ID of the effective valid or server-generated W3C Trace Context. Caller-supplied `tracestate` is never propagated. Problems contain no field-error array, SQL, paths, schema internals, selector values, source values, token material, or subject identifiers. Draft GovStack error namespaces are not used. Required audit or other release-gate failure prevents disclosure. | | Unsigned response boundary | Relay responses are not signed. TLS and access-token verification protect the live exchange, while revisions, ETags, provenance, and tamper-evident audit support accountability without being described as signatures. Evidence can consume a fixed Relay lookup when a portable signed minimum-disclosure assertion is required. | -| Audit and provenance | Every public or protected data request processed by Relay durably records either a refusal before returning or a pre-source attempt followed by one terminal release or unresolved outcome. Durable audit gates source access and response release. Events carry stable identifiers for Registry, resource, operation, access-rule revision, optional purpose, row-boundary kind, representation, disclosure profile, selected-property set or digest, processing handling, disclosure handling, transform identifiers, contract revision, and truthful source revision. Terminal audit covers exact held selected-profile bytes before release. Anonymous calls record an anonymous principal kind. Audit contains no tokens, selector values, source values, transformed values, response values, SQL, or raw subject identifiers. The safeguards report names public shared-cache hits as outside Relay observation. | +| Audit and provenance | Every public or protected data request processed by Relay durably records either a refusal before returning or a pre-source attempt followed by one terminal release or unresolved outcome. Durable audit gates source access and response release. Events carry stable identifiers for Registry, resource, operation, access-rule revision, optional purpose, row-boundary kind, representation, disclosure profile, selected-property set or digest, processing handling, disclosure handling, contract revision, and truthful source revision. Terminal audit covers exact held selected-profile bytes before release. Anonymous calls record an anonymous principal kind. Audit contains no tokens, selector values, source values, response values, SQL, or raw subject identifiers. The safeguards report names public shared-cache hits as outside Relay observation. | | Metadata visibility and per-profile artifacts | Registry service identity is public. Other resource, capability, OpenAPI, semantic, classification, processing, and operational metadata is `public`, `operation-bound` behind the same static gate as the operation and representation whose Record links it, or `operator-only` in package/CLI with no HTTP route. Public metadata never inventories a protected representation identifier, profile schema, SHACL shape, JSON-LD context, semantic model, classification, processing description, or OpenAPI path. The package contains every profile's artifacts and full OpenAPI; `/openapi.json` is a deterministic safe public projection. Compilation fails if any successful Record audience cannot resolve a safe operation-bound projection of its exact profile schema and semantic model. | | Freshness and caching | Snapshot and live responses expose truthful, profile-specific revision and cache behavior. Every public snapshot response uses `Cache-Control: public, no-cache`, a strong exact-byte ETag, and revalidation. Every non-public or live response is `no-store`. `Vary: Authorization` prevents an anonymous cached `200` from serving a request with an invalid bearer. No response implies a stable cross-request snapshot, and field subsets cannot collide. | | Generated contracts | OpenAPI 3.1, JSON Schema, SHACL, JSON-LD contexts, codelists, and capability discovery are generated reproducibly from the compiled contract. Full and public OpenAPI projections have drift checks. No artifact is generated from the obsolete Digital Registries OpenAPI. | @@ -93,7 +93,7 @@ For each of the three coequal registries: 5. ordinary JSON and JSON-LD are data-equivalent and validate against generated contracts; 6. default and explicitly requested representations, plus at least two valid `domainData` subsets within a selected representation, succeed while Registry Core remains complete; 7. an unknown property, source-column name, cross-profile property, duplicate property, malformed selection, malformed/repeated representation, unknown representation, and denied selected representation fail without source or value leakage or fallback; -8. invalid selected source rows and required transform null/type/length boundaries fail the whole response closed; every Registry proves at least one such refusal, and the coequal suite covers wrong type, missing required value, extra unexpected value, excessive size, partial-string, and date-precision boundaries; +8. invalid selected source rows fail the whole response closed with value-free `503 source.unavailable`; every Registry proves at least one such refusal, and the coequal suite covers wrong type, missing required value, extra unexpected value, and excessive size; 9. restarting with identical inputs reproduces the same compiled contract and generated artifacts; 10. a schema or governed-contract change is detected and cannot silently widen the active API; 11. full packaged OpenAPI, safe public OpenAPI, per-profile semantic, schema, SHACL, JSON-LD, classification, processing, codelist, and capability artifacts reproduce byte for byte. @@ -101,7 +101,7 @@ For each of the three coequal registries: Shared security acceptance additionally proves that: -1. a response and its emitted value-free audit correlate through trace and request-operation identifiers and agree on Registry, resource, compiled operation, contract, selected representation and disclosure profile, selected properties, processing/disclosure handling, transform identifiers, row-boundary kind, and truthful source revision; +1. a response and its emitted value-free audit correlate through trace and request-operation identifiers and agree on Registry, resource, compiled operation, contract, selected representation and disclosure profile, selected properties, processing/disclosure handling, row-boundary kind, and truthful source revision; 2. audit never records response bytes, response digests, Record identifiers, raw subject identifiers, or fixture canaries; 3. Problems do not contain fixture canaries, trace headers carry only Relay-validated fixed identifiers, and operational log dimensions cannot contain request paths, identifiers, query values, headers, bodies, selectors, or principals; 4. adopter reports and generated or packaged artifacts pass fixture-canary scans. @@ -171,9 +171,10 @@ value-free operational log dimensions. - a token minted for Evidence, another Relay audience, or an undeclared operation is rejected; - a public operation in a focused multi-resource test does not weaken a protected resource in that same process; - full packaged OpenAPI contains all operations while public OpenAPI omits every protected selector and operator-only artifact; -- unknown, protected, ambiguous, and unsafe lookup outcomes are identical except for trace correlation; +- unknown, protected, ambiguous, and policy-hidden lookup outcomes are identical except for trace + correlation, while a selected malformed source row fails closed as `503 source.unavailable`; - the alignment note records the deferred same-operation entitlement variant and every unimplemented family without a conformance claim; -- every security invariant has a named threat, enforcement point, stable negative case, and traceable test. +- every security invariant has a named threat, enforcement point, expected result, and exact executable negative-test traceability. ## Completion evidence diff --git a/products/relay-v2/IMPLEMENTATION.md b/products/relay-v2/IMPLEMENTATION.md index 461df7024..2ce958177 100644 --- a/products/relay-v2/IMPLEMENTATION.md +++ b/products/relay-v2/IMPLEMENTATION.md @@ -130,6 +130,7 @@ with: relay-package.json registry.yaml governed/... +compiled/registry.json generated/openapi.full.yaml generated/openapi.public.json generated/artifacts/... @@ -137,8 +138,10 @@ generated/artifacts/... `relay-package.json` is canonical JSON containing `packageVersion`, `packageRevision`, `contractRevision`, the expected SQLite schema fingerprint, -the generated-artifact inventory, and for every relative regular file its path, -size, SHA-256 digest, media type, visibility, and generated/authored status. +the generated-artifact inventory and operation bindings, and for every relative +regular file its path, size, SHA-256 digest, media type, visibility, and +generated/authored status. `compiled/registry.json` is the canonical compiled +runtime plan produced by the shared compiler. References cannot escape the directory and symlinks are rejected. The runtime file, sealed package tree, and their ancestry must be owned by root or the Relay service user and must not be writable by another account; only a @@ -151,9 +154,13 @@ authoring project or loose contract file. The complete governed file closure is captured into memory with file count, size, path, symlink, and permission bounds before parsing. Canonical typed -inputs produce `contractRevision`. Compilation is atomic and startup-only. -There is no hot reload, partial activation, overlay, fallback, or remote -vocabulary fetch. +inputs produce `contractRevision`. Compilation and artifact generation are +atomic packaging operations. Startup verifies canonical compiled bytes, source +schema bindings, governed-file and artifact digests, and operation-artifact +bindings. It recompiles the captured inputs solely to require exact equality +with the packaged runtime plan, then activates the packaged artifacts without +regenerating them. There is no hot reload, partial activation, overlay, +fallback, or remote vocabulary fetch. Registry Manifest projection is deferred portability tooling. Source columns, access rules, scopes, disclosure, classifications, processing constraints, and @@ -380,10 +387,11 @@ only, non-personal, unique, and cannot be named `pageSize`, `cursor`, or explicitly declares whether the empty subset is allowed with `allowUnfiltered`. `pageSize` is bounded by the operation default and maximum. Ordering is fixed with `recordIdentifier` as the unique tie-breaker. -The opaque authenticated cursor binds contract and source revisions, operation, +The client-opaque authenticated-encrypted cursor binds contract and source revisions, operation, filters, order, fields, authorization-relevant context, and expiry. Every page -is reauthorized. A caller cannot sort, name a source column, add an operator, or -traverse an uncompiled page. +is reauthorized. Authenticated encryption prevents filters and keyset-order +values from bypassing field minimization. A caller cannot sort, name a source +column, add an operator, or traverse an uncompiled page. The first page accepts `pageSize`, `fields`, and declared filters. A continuation request supplies exactly one `cursor` parameter and @@ -438,12 +446,14 @@ is exactly the effective W3C trace ID as 32 lowercase hexadecimal characters. Caller-supplied `tracestate` is never propagated because Relay cannot establish that vendor state is value-free. Audit and server logs use the same trace ID. -No match, ambiguity, policy-hidden record, unknown or protected identifier, and -invalid selected source row return the same `404` problem and headers. Only -independently generated trace correlation may differ. Malformed requests, -credentials, insufficient authority, unsupported representation, body size, -quota, internal failure, source failure, and audit failure use stable separate -Registry Stack codes without reflecting input values. Problems are `no-store`. +No match, ambiguity, policy-hidden record, and unknown or protected identifier +return the same `404` problem and headers. Only independently generated trace +correlation may differ. An invalid selected source row is an authoritative +source failure and returns the value-free `503 source.unavailable` problem. +Malformed requests, credentials, insufficient authority, unsupported +representation, body size, quota, internal failure, source failure, and audit +failure use stable separate Registry Stack codes without reflecting input +values. Problems are `no-store`. `401` includes `WWW-Authenticate`; Relay-owned `429` includes a coarse `Retry-After`. Version one does not freeze a successful-response `RateLimit` header contract. @@ -471,10 +481,11 @@ error array is emitted. | Malformed, expired, stale, or differently bound cursor | 400 | `query.cursor_invalid` | `cursor is invalid for this query` | | Missing credential on a protected operation | 401 | `auth.missing_credential` | `a bearer access token is required` | | Invalid credential | 401 | `auth.invalid_credential` | `bearer access token validation failed` | -| Insufficient scope, purpose, or row authority | 403 | `consultation.denied` | `the consultation is not permitted` | +| Valid credential without the operation scope | 404 | `resource.not_found` | `the requested resource was not found` | +| Insufficient purpose or row authority after scope selection | 403 | `consultation.denied` | `the consultation is not permitted` | | Unknown or visibility-hidden resource or artifact | 404 | `resource.not_found` | `the requested resource was not found` | | Unknown or unavailable requested representation | 404 | `representation.not_found` | `the requested representation was not found` | -| Unknown, hidden, ambiguous, or unsafe Record outcome | 404 | `consultation.unresolved` | `the requested record was not resolved` | +| Unknown, hidden, ambiguous, or policy-hidden Record outcome | 404 | `consultation.unresolved` | `the requested record was not resolved` | | Unsupported response `Accept` | 406 | `representation.unsupported` | `the requested representation is not supported` | | Request body too large | 413 | `internal.payload_too_large` | `request body exceeds the configured limit` | | Request URI too long | 414 | `internal.uri_too_long` | `request URI exceeds the configured limit` | @@ -696,8 +707,8 @@ contract and generic source/gate inventory may prepare that work without claiming a historic release published Relay V2. This is a release ownership boundary, not unfinished runtime behavior. -Gate: the runtime acceptance revision passes its focused source, image-contract, -and gate-inventory checks. The owning future release train runs release +Gate: the runtime acceptance revision passes its focused source and +gate-inventory checks. The owning future release train runs release validation, source-model, reproducibility, SBOM, and provenance checks when it publishes the artifacts. diff --git a/products/relay-v2/README.md b/products/relay-v2/README.md index e5b31650c..697a1c4d1 100644 --- a/products/relay-v2/README.md +++ b/products/relay-v2/README.md @@ -41,7 +41,6 @@ Run the focused product gates from the repository root: ```bash products/relay-v2/scripts/check-contracts.sh -products/relay-v2/scripts/check-generated.sh products/relay-v2/scripts/test-http.sh ``` diff --git a/products/relay-v2/acceptance/civil-event/codelists/civil-event-selector-types.yaml b/products/relay-v2/acceptance/civil-event/codelists/civil-event-selector-types.yaml new file mode 100644 index 000000000..93de9ff91 --- /dev/null +++ b/products/relay-v2/acceptance/civil-event/codelists/civil-event-selector-types.yaml @@ -0,0 +1,4 @@ +id: civil-event-selector-types +version: 1 +status: reviewed +values: [BIRTH, DEATH, MARRIAGE] diff --git a/products/relay-v2/acceptance/civil-event/expected-http.yaml b/products/relay-v2/acceptance/civil-event/expected-http.yaml index 950620d2e..898d744b6 100644 --- a/products/relay-v2/acceptance/civil-event/expected-http.yaml +++ b/products/relay-v2/acceptance/civil-event/expected-http.yaml @@ -111,14 +111,14 @@ steps: - id: scope-separation authorizationFixture: civil-verifier-ex-a request: {method: GET, path: /v2/resources/civil-event/records/EVENT-SYNTH-0001} - expect: {status: 403, code: consultation.denied} + expect: {status: 404, code: resource.not_found} - id: reverse-scope-separation authorizationFixture: civil-registrar-ex-a request: method: POST path: /v2/resources/civil-event/lookups/verify-registration body: {registrationNumber: REG-SYNTH-000001, eventType: BIRTH} - expect: {status: 403, code: consultation.denied} + expect: {status: 404, code: resource.not_found} - id: wrong-purpose authorizationFixture: civil-verifier-wrong-purpose request: @@ -192,15 +192,7 @@ steps: method: POST path: /v2/resources/civil-event/lookups/verify-registration body: {registrationNumber: REG-SYNTH-INVALID1, eventType: BIRTH} - expect: {status: 404, code: consultation.unresolved, equivalenceClass: unresolved} - - id: invalid-transform-input - authorizationFixture: civil-supervisor-ex-a - request: - method: POST - path: /v2/resources/civil-event/lookups/verify-registration - query: {representation: supervisory} - body: {registrationNumber: REG-SYNTH-XFORM1, eventType: BIRTH} - expect: {status: 404, code: consultation.unresolved, equivalenceClass: unresolved} + expect: {status: 503, code: source.unavailable} - id: quota-exhausted authorizationFixture: civil-verifier-ex-a request: diff --git a/products/relay-v2/acceptance/civil-event/registry.yaml b/products/relay-v2/acceptance/civil-event/registry.yaml index f193e31d5..2dc13283b 100644 --- a/products/relay-v2/acceptance/civil-event/registry.yaml +++ b/products/relay-v2/acceptance/civil-event/registry.yaml @@ -144,7 +144,7 @@ resources: maximumBytes: 384 selectors: registrationNumber: {sourceColumn: registration_number, type: string, minimumBytes: 12, maximumBytes: 96} - eventType: {sourceColumn: event_type, type: controlled-code, codelist: codelists/civil-event-types.yaml} + eventType: {sourceColumn: event_type, type: controlled-code, codelist: codelists/civil-event-selector-types.yaml} defaultRepresentation: registrar-verification representations: registrar-verification: diff --git a/products/relay-v2/acceptance/social-assistance/expected-http.yaml b/products/relay-v2/acceptance/social-assistance/expected-http.yaml index ae348a717..dbf3af727 100644 --- a/products/relay-v2/acceptance/social-assistance/expected-http.yaml +++ b/products/relay-v2/acceptance/social-assistance/expected-http.yaml @@ -202,21 +202,14 @@ steps: method: POST path: /v2/resources/assistance-enrolment/lookups/by-case-and-person body: {caseReference: CASE-SYNTH-BAD1, personReference: PERSON-SYNTH-BAD1} - expect: {status: 404, code: consultation.unresolved, equivalenceClass: unresolved} + expect: {status: 503, code: source.unavailable} - id: excessive-row authorizationFixture: social-lookup-area-a request: method: POST path: /v2/resources/assistance-enrolment/lookups/by-case-and-person body: {caseReference: CASE-SYNTH-BAD2, personReference: PERSON-SYNTH-BAD2} - expect: {status: 404, code: consultation.unresolved, equivalenceClass: unresolved} - - id: invalid-transform-input - authorizationFixture: social-lookup-area-a - request: - method: POST - path: /v2/resources/assistance-enrolment/lookups/by-case-and-person - body: {caseReference: CASE-SYNTH-XFORM, personReference: PERSON-SYNTH-XFORM} - expect: {status: 404, code: consultation.unresolved, equivalenceClass: unresolved} + expect: {status: 503, code: source.unavailable} - id: quota-exhausted authorizationFixture: social-lookup-area-a request: diff --git a/products/relay-v2/contracts/acceptance-scenario-matrix.yaml b/products/relay-v2/contracts/acceptance-scenario-matrix.yaml index c7210474d..8807d7aaa 100644 --- a/products/relay-v2/contracts/acceptance-scenario-matrix.yaml +++ b/products/relay-v2/contracts/acceptance-scenario-matrix.yaml @@ -23,9 +23,8 @@ scenarios: - {id: social-no-match, project: social-assistance, journeyStep: no-match, assertion: A missing lookup has the unresolved outcome.} - {id: social-ambiguous, project: social-assistance, journeyStep: ambiguous, assertion: An ambiguous lookup has the unresolved outcome.} - {id: social-hidden, project: social-assistance, journeyStep: row-hidden, assertion: A row outside the caller binding has the unresolved outcome.} - - {id: social-invalid, project: social-assistance, journeyStep: invalid-row, invalidSourceRowClass: missing-required, assertion: An invalid selected row has the unresolved outcome.} - - {id: social-excessive, project: social-assistance, journeyStep: excessive-row, invalidSourceRowClass: excessive-size, assertion: An excessively large source value fails closed as unresolved.} - - {id: social-invalid-transform, project: social-assistance, journeyStep: invalid-transform-input, invalidSourceRowClass: excessive-size, assertion: An oversized partial-string source fails closed as unresolved.} + - {id: social-invalid, project: social-assistance, journeyStep: invalid-row, invalidSourceRowClass: missing-required, expectedStatus: 503, expectedCode: source.unavailable, assertion: An invalid selected row fails closed as a source failure.} + - {id: social-excessive, project: social-assistance, journeyStep: excessive-row, invalidSourceRowClass: excessive-size, expectedStatus: 503, expectedCode: source.unavailable, assertion: An excessively large source value fails closed as a source failure.} - {id: social-quota, project: social-assistance, journeyStep: quota-exhausted, assertion: The named lookup fails closed after its compiled-operation quota is exhausted.} - {id: business-discovery, project: business-registry, journeyStep: registry-discovery, assertion: Discovery derives only the public Consultation capabilities.} - {id: business-first-page, project: business-registry, journeyStep: first-page, assertion: The snapshot list is paginated and has a cursor.} @@ -48,7 +47,7 @@ scenarios: - {id: business-filter-rejection, project: business-registry, journeyStep: unknown-filter, assertion: An undeclared filter fails before source access.} - {id: business-sort-rejection, project: business-registry, journeyStep: arbitrary-sort, assertion: Callers cannot choose source ordering.} - {id: business-operator-rejection, project: business-registry, journeyStep: unsupported-filter-operator, assertion: Callers cannot add an operator to a declared direct filter.} - - {id: business-invalid, project: business-registry, journeyStep: invalid-source-row, invalidSourceRowClass: wrong-type, assertion: An invalid source row is not released.} + - {id: business-invalid, project: business-registry, journeyStep: invalid-source-row, invalidSourceRowClass: wrong-type, expectedStatus: 503, expectedCode: source.unavailable, assertion: An invalid source row fails closed and is not released.} - {id: civil-discovery, project: civil-event, journeyStep: registry-discovery, assertion: Discovery derives retrieve and constrained search only.} - {id: civil-read, project: civil-event, journeyStep: registrar-read, assertion: The registrar scope receives its narrower requested representation.} - {id: civil-read-default, project: civil-event, journeyStep: registrar-read-default, assertion: The default representation contains exactly the compiled disclosure profile.} @@ -70,6 +69,5 @@ scenarios: - {id: civil-hidden, project: civil-event, journeyStep: jurisdiction-hidden, assertion: A row outside the jurisdiction binding has the unresolved outcome.} - {id: civil-no-match, project: civil-event, journeyStep: no-match, assertion: A missing civil-event lookup has the unresolved outcome.} - {id: civil-ambiguous, project: civil-event, journeyStep: ambiguous, assertion: An ambiguous lookup has the unresolved outcome.} - - {id: civil-invalid, project: civil-event, journeyStep: invalid-row, invalidSourceRowClass: unexpected-value, assertion: An invalid source row has the unresolved outcome.} - - {id: civil-invalid-transform, project: civil-event, journeyStep: invalid-transform-input, invalidSourceRowClass: unexpected-value, assertion: A noncanonical date-precision source fails closed as unresolved.} + - {id: civil-invalid, project: civil-event, journeyStep: invalid-row, invalidSourceRowClass: unexpected-value, expectedStatus: 503, expectedCode: source.unavailable, assertion: An invalid source row fails closed as a source failure.} - {id: civil-quota, project: civil-event, journeyStep: quota-exhausted, assertion: A distinct named lookup has its own bounded quota and value-free refusal.} diff --git a/products/relay-v2/contracts/generated-baselines.yaml b/products/relay-v2/contracts/generated-baselines.yaml index 3ea0db043..77db4866a 100644 --- a/products/relay-v2/contracts/generated-baselines.yaml +++ b/products/relay-v2/contracts/generated-baselines.yaml @@ -850,6 +850,12 @@ projects: sha256: sha256:2dc557335daf6824d9a037998ef20fa1efe835c877fb46feec1e3fd2d19ac392 visibility: public governedFiles: + - generated: false + mediaType: application/yaml + path: governed/codelists/civil-event-selector-types.yaml + sha256: sha256:b50078dbd85a1aef52a98394578e8d4b8e3132833f9e5d785b0f60854d0d53c6 + size: 92 + visibility: operator-only - generated: false mediaType: application/yaml path: governed/codelists/civil-event-types.yaml diff --git a/products/relay-v2/contracts/security-invariant-matrix.yaml b/products/relay-v2/contracts/security-invariant-matrix.yaml index 3c60956bb..db33090ca 100644 --- a/products/relay-v2/contracts/security-invariant-matrix.yaml +++ b/products/relay-v2/contracts/security-invariant-matrix.yaml @@ -5,44 +5,72 @@ invariants: - id: sec-contract-runtime-separation threat: Deployment configuration weakens governed disclosure or authorization. enforcementPoint: RegistryContract and RelayRuntime closed-schema compilation before readiness. - negativeCase: runtime_governed_override_is_rejected expected: Runtime configuration cannot add or alter resources, operations, disclosure, semantics, classification, access, or metadata visibility. evidence: config-validation + negativeTest: runtime_rejects_governed_override tests: - {path: crates/registry-relay-v2/src/contract.rs, name: runtime_rejects_governed_override} + - id: sec-package-activation-integrity + threat: A sealed package activates a compiled access or disclosure model that was not derived from its captured contract and governed files. + enforcementPoint: Package construction and startup independently reproduce the compiled Registry from the captured contract, observed schemas, and governed closure and require exact equality before activation. + expected: Same-identity semantic mismatch, file tampering, or inconsistent artifacts prevent packaging or readiness before source or listener activation. + evidence: sealed-package-derivation-tests + negativeTest: sealed_package_reproduces_and_tampering_is_refused + tests: + - {path: crates/registry-relay-v2/src/package.rs, name: sealed_package_reproduces_and_tampering_is_refused} - id: sec-one-registry-boundary threat: Source, operation, disclosure, quota, or audit state crosses resource boundaries within one compiled Registry. enforcementPoint: Resource-qualified compiled operations and operation-keyed runtime state. - negativeCase: multi_resource_state_crosses_a_resource_boundary expected: Multi-resource compilation and routing keep every resource's query, disclosure, authorization, quota, response, and audit state local. evidence: compiler-and-multi-resource-acceptance + negativeTest: real_router_keeps_related_public_and_protected_resources_isolated tests: - {path: crates/registry-relay-v2/tests/multi_resource_isolation.rs, name: compiler_keeps_every_multi_resource_operation_boundary_local} - {path: crates/registry-relay-v2/tests/multi_resource_isolation.rs, name: real_router_keeps_related_public_and_protected_resources_isolated} - id: sec-sqlite-read-only threat: A request mutates, attaches, extends, or escapes the reviewed SQLite source. enforcementPoint: OS read-only access, SQLite authorizer, fixed generated statements, bound values, and resource budgets. - negativeCase: sqlite_write_attach_extension_and_unreviewed_sql_are_rejected expected: No public request can execute a write, schema change, attachment, extension, control statement, or caller-authored SQL. evidence: platform-sqlite-negative-tests + negativeTest: every_mutating_or_connection_widening_action_is_refused tests: - {path: crates/registry-platform-sqlite/tests/kernel.rs, name: every_mutating_or_connection_widening_action_is_refused} - {path: crates/registry-platform-sqlite/tests/kernel.rs, name: row_cell_and_response_bounds_are_enforced} + - id: sec-sqlite-connection-recovery + threat: A cancelled, interrupted, or refused statement leaves transaction, authorizer, progress-handler, or pool state that changes a later request. + enforcementPoint: Statement cleanup verifies reusable state and discards or replaces a connection that cannot be restored. + expected: Step, time, engine, and authorizer failures cannot leak state into the next statement or strand a pool permit. + evidence: platform-sqlite-recovery-tests + negativeTest: step_deadline_engine_and_authorizer_failures_leave_connection_reusable + tests: + - {path: crates/registry-platform-sqlite/tests/kernel.rs, name: the_step_budget_interrupts_an_expensive_statement_and_the_pool_recovers} + - {path: crates/registry-platform-sqlite/tests/kernel.rs, name: the_time_budget_interrupts_an_expensive_statement_and_the_pool_recovers} + - {path: crates/registry-platform-sqlite/src/statement.rs, name: step_deadline_engine_and_authorizer_failures_leave_connection_reusable} + - {path: crates/registry-platform-sqlite/src/statement.rs, name: a_nonreusable_connection_is_discarded_and_replaced} - id: sec-token-profile-closed threat: A malformed, wrong-audience, not-yet-valid, expired, or malformed-principal token authorizes access. enforcementPoint: JWT access-token verification and strict principal extraction before access decisions. - negativeCase: malformed_audience_time_and_principal_tokens_fail_closed expected: Malformed JWTs, non-scalar audiences, wrong audiences, future issuance, expiry, and malformed subjects are refused as invalid credentials. evidence: real-router-authentication-tests + negativeTest: real_jwt_path_rejects_malformed_audience_time_and_expired_tokens tests: - {path: crates/registry-relay-v2/tests/acceptance_http.rs, name: real_jwt_path_rejects_malformed_audience_time_and_expired_tokens} - {path: crates/registry-relay-v2/src/auth.rs, name: malformed_subject_cannot_fall_back_to_client_identifier} + - id: sec-resource-existence-concealment + threat: Authentication or scope failures reveal whether a protected resource, operation, or identifier exists. + enforcementPoint: Authentication precedes route selection and a verified principal without the operation scope receives the unknown-resource response and generic refusal audit. + expected: Invalid bearer requests fail generically, while insufficient-scope and unknown data surfaces are externally and audit-wise indistinguishable without source access. + evidence: real-router-existence-concealment-tests + negativeTest: insufficient_scope_and_unknown_data_surfaces_are_indistinguishable + tests: + - {path: crates/registry-relay-v2/tests/acceptance_http.rs, name: invalid_bearer_on_unknown_data_routes_is_audited_fail_closed} + - {path: crates/registry-relay-v2/tests/acceptance_http.rs, name: insufficient_scope_and_unknown_data_surfaces_are_indistinguishable} - id: sec-operation-confinement threat: A token or request enables an operation the Registry did not compile. enforcementPoint: Hardcoded route construction from compiled operation kinds and exact operation scopes. - negativeCase: lookup_scope_cannot_enable_list_or_read expected: Missing routes remain absent and scopes never synthesize capabilities. evidence: route-inventory-and-scope-tests + negativeTest: all_three_registry_http_journeys_use_the_real_router tests: - {path: crates/registry-relay-v2/tests/acceptance_http.rs, name: all_three_registry_http_journeys_use_the_real_router} - {path: crates/registry-relay-v2/tests/multi_resource_isolation.rs, name: real_router_keeps_related_public_and_protected_resources_isolated} @@ -100,79 +128,89 @@ invariants: - id: sec-operation-quota threat: One named lookup exhausts another resource or operation's request budget. enforcementPoint: Runtime quota configuration compiled into an operation-keyed limiter before readiness. - negativeCase: lookup_quota_exhaustion_is_operation_scoped expected: Every named lookup has a bounded quota and exhaustion remains isolated to its compiled operation. evidence: real-router-quota-tests + negativeTest: quota_is_scoped_to_the_compiled_operation tests: - {path: crates/registry-relay-v2/src/server.rs, name: quota_is_scoped_to_the_compiled_operation} - {path: crates/registry-relay-v2/tests/acceptance_http.rs, name: all_three_registry_http_journeys_use_the_real_router} - id: sec-trusted-context threat: Caller input fabricates purpose or row authority. enforcementPoint: Purpose and principal row binding come only from verified claims and compiler-injected predicates. - negativeCase: headers_and_filters_cannot_satisfy_purpose_or_row_binding expected: Caller headers, selectors, and filters never create trusted authority. evidence: authorization-negative-tests + negativeTest: trusted_purpose_and_row_binding_refusals_use_only_verified_claims tests: - {path: crates/registry-relay-v2/src/compiler.rs, name: row_authority_is_a_compiler_injected_lane_not_a_filter} - {path: crates/registry-relay-v2/tests/acceptance_http.rs, name: trusted_purpose_and_row_binding_refusals_use_only_verified_claims} - id: sec-disclosure-monotonic threat: Field selection expands disclosure, changes predicates, or exposes source columns. enforcementPoint: One compiled maximum disclosure per operation followed by subset-only projection. - negativeCase: unknown_duplicate_hidden_and_source_fields_are_rejected_before_io expected: fields can only remove selectable domainData properties; Registry Core remains present. evidence: disclosure-and-query-plan-tests + negativeTest: all_three_registry_http_journeys_use_the_real_router tests: - {path: crates/registry-relay-v2/tests/acceptance_http.rs, name: all_three_registry_http_journeys_use_the_real_router} - id: sec-lookup-non-enumeration - threat: Exact lookup reveals selector values, protected existence, ambiguity, policy outcomes, or invalid source data. + threat: Exact lookup reveals selector values, protected existence, ambiguity, or policy outcomes. enforcementPoint: Bounded body parsing, bound selectors, at-most-two-row execution, and one unresolved problem contract. - negativeCase: unresolved_lookup_outcomes_are_indistinguishable - expected: No match, ambiguity, hidden Record, unknown or protected identifier, and unsafe selected row share the same value-free outcome except independent trace correlation. + expected: No match, ambiguity, hidden Record, and unknown or protected identifier share the same value-free outcome except independent trace correlation; an invalid selected source row fails the whole response as value-free source.unavailable. evidence: real-router-collapse-tests + negativeTest: unresolved_lookup_causes_have_one_public_body tests: - {path: crates/registry-relay-v2/src/problem.rs, name: unresolved_lookup_causes_have_one_public_body} - {path: crates/registry-relay-v2/tests/acceptance_http.rs, name: all_three_registry_http_journeys_use_the_real_router} + - id: sec-malformed-row-atomicity + threat: A malformed selected source row is coerced, skipped, partially released, or mistaken for a normal unresolved lookup. + enforcementPoint: Full source-row and cursor-order validation occurs before response serialization, followed by a source-failed terminal audit gate. + expected: Every malformed selected row discards the entire held response and returns value-free 503 source.unavailable. + evidence: real-router-malformed-row-tests + negativeTest: malformed_disclosed_property_type_and_requiredness_fail_closed + tests: + - {path: crates/registry-relay-v2/tests/acceptance_http.rs, name: malformed_disclosed_property_type_and_requiredness_fail_closed} + - {path: crates/registry-relay-v2/tests/acceptance_http.rs, name: business_list_with_a_late_malformed_row_fails_atomically} - id: sec-reference-visibility threat: A Record leaks protected metadata or points its caller to an unresolvable schema or semantic model. enforcementPoint: Compile-time visibility closure across operation, resource, schema, semantic, capability, and OpenAPI artifacts. - negativeCase: record_cannot_reference_a_less_visible_required_artifact expected: Every successful caller can resolve safe projections of the exact schema and semantic model referenced by the Record. evidence: metadata-exposure-inventory + negativeTest: public_record_cannot_reference_operator_only_semantics tests: - {path: crates/registry-relay-v2/src/compiler.rs, name: public_record_cannot_reference_operator_only_semantics} - {path: crates/registry-relay-v2/tests/acceptance_http.rs, name: all_three_registry_http_journeys_use_the_real_router} - id: sec-audit-release-gate threat: Source access or response bytes escape without durable accountability. enforcementPoint: Durable attempt before source I/O and durable refusal or release before returning exact held bytes. - negativeCase: audit_failure_blocks_source_access_or_release expected: Every data operation, including public release, fails closed at the relevant audit gate. evidence: audit-ordering-tests + negativeTest: audit_terminal_failure_discards_held_record_bytes tests: - {path: crates/registry-relay-v2/tests/acceptance_http.rs, name: audit_attempt_failure_prevents_source_access} - {path: crates/registry-relay-v2/tests/acceptance_http.rs, name: audit_terminal_failure_discards_held_record_bytes} - id: sec-audit-correlation-and-minimization threat: An emitted audit event cannot be correlated to the released operation or records protected request, response, source-row, or principal values. enforcementPoint: Relay-owned closed audit vocabulary populated from compiled and verified operation context. - negativeCase: emitted_audit_disagrees_with_response_or_contains_fixture_canaries expected: Emitted attempt and terminal events correlate by trace and request-operation identifiers, agree with the released Registry, resource, operation, contract, disclosure, selected-property, row-boundary, and source-revision context, and contain no response bytes, Record identifiers, raw principals, or fixture canaries. evidence: real-router-recording-audit-proof + negativeTest: real_router_keeps_related_public_and_protected_resources_isolated tests: - {path: crates/registry-relay-v2/tests/multi_resource_isolation.rs, name: real_router_keeps_related_public_and_protected_resources_isolated} - id: sec-cursor-integrity threat: A caller tampers with pagination state or reuses it across revisions, operations, fields, filters, or authority contexts. - enforcementPoint: Authenticated opaque cursor verification and per-page reauthorization. - negativeCase: cursor_context_or_revision_change_is_rejected - expected: A cursor is usable only under its exact compiled and authorized context before expiry. + enforcementPoint: Authenticated encryption of the complete cursor payload plus per-page reauthorization. + expected: A cursor reveals no filter, selected-field, authorization, or keyset-order value and is usable only under its exact compiled and authorized context before expiry. evidence: cursor-negative-tests + negativeTest: cursor_conceals_order_values_and_refuses_tampering tests: - - {path: crates/registry-relay-v2/src/cursor.rs, name: cursor_is_opaque_and_refuses_tampering} - - {path: crates/registry-relay-v2/src/cursor.rs, name: cursor_cannot_cross_authorization_or_filter_contexts} + - {path: crates/registry-relay-v2/src/cursor.rs, name: cursor_conceals_order_values_and_refuses_tampering} + - {path: crates/registry-relay-v2/src/cursor.rs, name: encrypting_the_same_cursor_twice_uses_distinct_nonces} + - {path: crates/registry-relay-v2/src/cursor.rs, name: cursor_refuses_every_mismatched_request_binding_and_expiry} - id: sec-source-truthfulness threat: Relay serves incompatible schema, mixed live state, or false revision and cache claims. enforcementPoint: Schema fingerprint, one transaction per request, source-profile revisions, and path identity checks. - negativeCase: schema_drift_path_replacement_and_stale_cursor_fail_closed expected: Relay reports only revisions it can establish and never claims snapshot consistency for unversioned live data. evidence: source-profile-tests + negativeTest: live_reads_allow_content_updates_but_refuse_path_replacement tests: - {path: crates/registry-platform-sqlite/tests/kernel.rs, name: a_snapshot_is_digest_bound_and_read_immutably} - {path: crates/registry-platform-sqlite/tests/kernel.rs, name: live_reads_allow_content_updates_but_refuse_path_replacement} @@ -182,9 +220,9 @@ invariants: - id: sec-value-free-diagnostics threat: SQL, source paths, or fixture row values appear in SQLite or adopter-tooling errors. enforcementPoint: Typed SQLite errors and closed adopter-tooling diagnostics. - negativeCase: sqlite_and_tooling_errors_render_no_sql_paths_or_row_values expected: SQLite errors render no SQL, paths, or bound values, and fixture-tooling failures render no source row values. evidence: focused-error-redaction-tests + negativeTest: errors_never_render_sql_paths_or_values tests: - {path: crates/registry-platform-sqlite/tests/kernel.rs, name: errors_never_render_sql_paths_or_values} - {path: crates/registry-relay-v2/src/tooling.rs, name: errors_never_render_paths} @@ -192,9 +230,9 @@ invariants: - id: sec-value-free-operational-logs threat: Request paths, identifiers, query values, or dependency traces enter operational logs. enforcementPoint: Closed Relay-owned log filter plus fixed method, route-template, status, latency, and trace dimensions. - negativeCase: hostile_log_configuration_or_request_values_cannot_widen_operational_logs expected: Operational logging cannot enable dependency targets and never records a dynamic path segment, query string, header, body, selector, Record identifier, or principal identifier. evidence: focused-operational-log-tests + negativeTest: operational_log_filter_cannot_enable_dependency_targets tests: - {path: crates/registry-relay-v2/src/main.rs, name: operational_log_filter_cannot_enable_dependency_targets} - {path: crates/registry-relay-v2/src/server.rs, name: operational_dimensions_never_include_request_values} @@ -202,9 +240,9 @@ invariants: - id: sec-value-free-trace-context threat: Invalid or caller-controlled trace state is normalized or reflected through ordinary and Problem response headers. enforcementPoint: Strict lowercase version-zero traceparent parser and Relay-owned response Trace Context projection. - negativeCase: invalid_traceparent_or_caller_tracestate_is_reflected expected: Invalid traceparent is replaced with server context, and Relay never emits caller-supplied tracestate even when it is syntactically valid. evidence: focused-trace-context-tests + negativeTest: invalid_traceparent_is_replaced_with_server_context tests: - {path: crates/registry-relay-v2/src/problem.rs, name: version_zero_traceparent_rejects_non_lowercase_hex} - {path: crates/registry-relay-v2/src/problem.rs, name: invalid_traceparent_is_replaced_with_server_context} @@ -212,9 +250,9 @@ invariants: - id: sec-unsigned-family-boundary threat: Relay output is mistaken for portable signed Evidence or API authentication is mistaken for Identity Federation. enforcementPoint: Product routes, dependencies, discovery, and family-alignment inventory. - negativeCase: relay_never_advertises_or_emits_unimplemented_family_artifacts expected: Relay advertises only compiled Consultation patterns, signs no response, and exposes no credential, federation, write, notification, aggregate, or access-history surface. evidence: family-alignment-and-route-inventory + negativeTest: generated_inventory_covers_required_v1_artifact_classes_only tests: - {path: crates/registry-relay-v2/src/artifacts.rs, name: generated_inventory_covers_required_v1_artifact_classes_only} - {path: crates/registry-relay-v2/tests/acceptance_http.rs, name: all_three_registry_http_journeys_use_the_real_router} diff --git a/products/relay-v2/scripts/check-exposure-inventory.sh b/products/relay-v2/scripts/check-exposure-inventory.sh deleted file mode 100755 index ea77d6f43..000000000 --- a/products/relay-v2/scripts/check-exposure-inventory.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -exec bash "$SCRIPT_DIR/check-generated.sh" "$@" diff --git a/products/relay-v2/scripts/check-source-neutrality.sh b/products/relay-v2/scripts/check-source-neutrality.sh index a6f380e54..c38a77861 100755 --- a/products/relay-v2/scripts/check-source-neutrality.sh +++ b/products/relay-v2/scripts/check-source-neutrality.sh @@ -8,7 +8,8 @@ forbidden='social[-_ ]?assistance|business[-_ ]?registry|civil[-_ ]?event|crvs|b if rg -i -l "$forbidden" \ "$PRODUCT_DIR/../../crates/registry-relay-v2/src" \ - "$PRODUCT_DIR/../../crates/registry-relayctl/src" >/dev/null; then + "$PRODUCT_DIR/../../crates/registry-relayctl/src" \ + "$PRODUCT_DIR/../../crates/registry-platform-sqlite/src" >/dev/null; then echo "relay-v2 source-neutrality: acceptance-domain term in Relay V2 production source" >&2 exit 1 fi diff --git a/products/relay-v2/scripts/test_adopter_workflow.py b/products/relay-v2/scripts/test_adopter_workflow.py index 930fac523..1b4f7333c 100755 --- a/products/relay-v2/scripts/test_adopter_workflow.py +++ b/products/relay-v2/scripts/test_adopter_workflow.py @@ -89,14 +89,6 @@ def file_sha256(path: Path) -> str: return f"sha256:{hashlib.sha256(path.read_bytes()).hexdigest()}" -def tree_hashes(root: Path) -> dict[str, str]: - return { - path.relative_to(root).as_posix(): file_sha256(path) - for path in sorted(root.rglob("*")) - if path.is_file() - } - - def openapi_operations(document: dict[str, Any]) -> dict[tuple[str, str], dict[str, Any]]: result: dict[tuple[str, str], dict[str, Any]] = {} if document.get("openapi") != "3.1.0" or not isinstance(document.get("paths"), dict): @@ -255,15 +247,27 @@ def validate_openapi(package: Path, artifacts: list[dict[str, Any]]) -> None: def validate_exposure_and_identity(package: Path, generated: Path) -> dict[str, Any]: manifest = json.loads((package / "relay-package.json").read_text(encoding="utf-8")) - if manifest.get("packageVersion") != "relay.registrystack.org/package/v1alpha1": + if manifest.get("packageVersion") != "relay.registrystack.org/package/v1alpha2": raise GateFailure("sealed package has an unsupported manifest") artifacts = manifest.get("artifacts") + operation_bindings = manifest.get("operationArtifactBindings") files = manifest.get("files") - if not isinstance(artifacts, list) or not isinstance(files, list): + if ( + not isinstance(artifacts, list) + or not isinstance(operation_bindings, list) + or not isinstance(files, list) + ): raise GateFailure("sealed package inventory is incomplete") file_inventory = {entry["path"]: entry for entry in files} if len(file_inventory) != len(files): raise GateFailure("sealed package contains duplicate file inventory paths") + compiled = file_inventory.get("compiled/registry.json") + if ( + not compiled + or not compiled.get("generated") + or compiled.get("visibility") != "operator-only" + ): + raise GateFailure("sealed package omits its operator-only compiled Registry") for entry in files: path = package / entry["path"] if not path.is_file() or file_sha256(path) != entry.get("sha256"): @@ -309,6 +313,57 @@ def baseline(manifest: dict[str, Any]) -> dict[str, Any]: } +def assert_diff_change(report: dict[str, Any], change_class: str, impact: str) -> None: + changes = report.get("details", {}).get("report", {}).get("changes", []) + if not any( + change.get("class") == change_class and change.get("impact") == impact + for change in changes + if isinstance(change, dict) + ): + raise GateFailure( + f"relayctl diff did not classify {change_class} as {impact}" + ) + + +def exercise_nontrivial_diff( + accepted: Any, project_name: str, project: Path, previous: Path, root: Path +) -> None: + if project_name != "business-registry": + return + + def changed_project(name: str) -> tuple[Path, dict[str, Any]]: + candidate = root / name + shutil.copytree(project, candidate) + contract_path = candidate / "registry.yaml" + contract = yaml.safe_load(contract_path.read_text(encoding="utf-8")) + return candidate, contract + + expanded, contract = changed_project("diff-expanded") + pagination = contract["resources"][0]["operations"]["list"]["pagination"] + pagination["maximumPageSize"] += 1 + (expanded / "registry.yaml").write_text( + yaml.safe_dump(contract, sort_keys=False, width=1000), encoding="utf-8" + ) + report = accepted(["diff", str(previous), str(expanded)]) + assert_diff_change(report, "pagination-expanded", "widening") + + narrowed, contract = changed_project("diff-narrowed") + contract["resources"][0]["operations"]["list"]["allowUnfiltered"] = False + (narrowed / "registry.yaml").write_text( + yaml.safe_dump(contract, sort_keys=False, width=1000), encoding="utf-8" + ) + report = accepted(["diff", str(previous), str(narrowed)]) + assert_diff_change(report, "unfiltered-disabled", "narrowing") + + breaking, contract = changed_project("diff-breaking") + contract["resources"][0]["operations"]["list"]["filters"].pop(0) + (breaking / "registry.yaml").write_text( + yaml.safe_dump(contract, sort_keys=False, width=1000), encoding="utf-8" + ) + report = accepted(["diff", str(previous), str(breaking)]) + assert_diff_change(report, "filter-removed", "breaking") + + def run_workflow(relayctl: Path, project_name: str, root: Path) -> tuple[list[dict[str, Any]], list[bytes], dict[str, Any]]: source = PRODUCT_ROOT / "acceptance" / project_name project = root / "project" @@ -338,7 +393,10 @@ def accepted(arguments: list[str]) -> dict[str, Any]: check = accepted(["check", str(project), "--production"]) accepted(["generate", str(project), "--output", str(root / "generated")]) accepted(["test", str(project)]) - accepted(["diff", str(previous), str(project)]) + no_op_diff = accepted(["diff", str(previous), str(project)]) + if no_op_diff.get("details", {}).get("report", {}).get("changes") != []: + raise GateFailure(f"{project_name}: byte-identical projects produced a diff") + exercise_nontrivial_diff(accepted, project_name, project, previous, root) package_report = accepted(["package", str(project), "--output", str(root / "package")]) manifest = validate_exposure_and_identity(root / "package", root / "generated") @@ -415,29 +473,13 @@ def main() -> int: snapshots: dict[str, Any] = {} key_paths = {"registry": set(), "runtime": set()} for project_name in PROJECTS: - with tempfile.TemporaryDirectory(prefix=f"relay-v2-{project_name}-first-") as first_raw: - with tempfile.TemporaryDirectory(prefix=f"relay-v2-{project_name}-second-") as second_raw: - first = Path(first_raw) - second = Path(second_raw) - first_reports, first_outputs, first_result = run_workflow( - relayctl, project_name, first - ) - second_reports, second_outputs, second_result = run_workflow( - relayctl, project_name, second - ) - if first_reports != second_reports: - raise GateFailure(f"{project_name}: adopter reports are not deterministic") - if tree_hashes(first / "generated") != tree_hashes(second / "generated"): - raise GateFailure(f"{project_name}: generated artifacts are not deterministic") - if tree_hashes(first / "package") != tree_hashes(second / "package"): - raise GateFailure(f"{project_name}: sealed package is not deterministic") - canaries = protected_canaries(PRODUCT_ROOT / "acceptance" / project_name) - assert_value_free(first_outputs + second_outputs, canaries, project_name) - snapshots[project_name] = baseline(first_result["manifest"]) - if first_result != second_result: - raise GateFailure(f"{project_name}: shared inventories are not deterministic") - for kind in key_paths: - key_paths[kind].update(first_result["keyPaths"][kind]) + with tempfile.TemporaryDirectory(prefix=f"relay-v2-{project_name}-") as raw: + _, outputs, result = run_workflow(relayctl, project_name, Path(raw)) + canaries = protected_canaries(PRODUCT_ROOT / "acceptance" / project_name) + assert_value_free(outputs, canaries, project_name) + snapshots[project_name] = baseline(result["manifest"]) + for kind in key_paths: + key_paths[kind].update(result["keyPaths"][kind]) baseline_document = { "schemaVersion": "relay.registrystack.org/generated-baselines/v1alpha1", diff --git a/products/relay-v2/scripts/test_validate_product.py b/products/relay-v2/scripts/test_validate_product.py index 79ffb6cd7..f7c9af462 100644 --- a/products/relay-v2/scripts/test_validate_product.py +++ b/products/relay-v2/scripts/test_validate_product.py @@ -105,24 +105,6 @@ def load_without_social_lookup_default(path: Path): errors, ) - def test_generated_review_must_keep_its_required_method(self) -> None: - original = VALIDATOR.load_yaml - - def load_with_manual_social_review(path: Path): - value = copy.deepcopy(original(path)) - if path.name == "classification-review.yaml" and path.parent.name == "governance": - if path.parents[1].name == "social-assistance": - value["method"] = "manual" - value.pop("generatedIdentification") - return value - - errors: list[str] = [] - with mock.patch.object(VALIDATOR, "load_yaml", side_effect=load_with_manual_social_review): - VALIDATOR.validate_acceptance_representation_contracts(errors) - self.assertTrue( - any("does not use the required reviewed method" in error for error in errors), errors - ) - def test_social_quota_fixture_is_bound_to_the_pre_quota_journey(self) -> None: original = VALIDATOR.load_yaml @@ -155,6 +137,31 @@ def load_with_civil_quota_drift(path: Path): any("lookup quota fixture must admit exactly" in error for error in errors), errors ) + def test_invalid_source_row_scenario_must_match_the_executable_failure(self) -> None: + original = VALIDATOR.load_yaml + + def load_with_unresolved_source_row(path: Path): + value = copy.deepcopy(original(path)) + if path.name == "acceptance-scenario-matrix.yaml": + scenario = next( + item for item in value["scenarios"] if item.get("invalidSourceRowClass") + ) + scenario["expectedStatus"] = 404 + scenario["expectedCode"] = "consultation.unresolved" + return value + + errors: list[str] = [] + with mock.patch.object( + VALIDATOR, "load_yaml", side_effect=load_with_unresolved_source_row + ): + VALIDATOR.validate_catalogs(errors) + self.assertTrue( + any("must expect 503 source.unavailable" in error for error in errors), errors + ) + self.assertTrue( + any("expectation disagrees with the journey" in error for error in errors), errors + ) + def test_security_test_resolution_rejects_a_similar_prefix(self) -> None: errors: list[str] = [] VALIDATOR.executable_test_resolves( @@ -193,6 +200,52 @@ def test_unannotated_function_is_not_executable_evidence(self) -> None: self.assertEqual(1, len(errors), errors) self.assertIn("exact executable test does not resolve", errors[0]) + def test_security_inventory_rejects_a_deleted_invariant(self) -> None: + original = VALIDATOR.load_yaml + + def load_without_one_invariant(path: Path): + value = copy.deepcopy(original(path)) + if path.name == "security-invariant-matrix.yaml": + value["invariants"].pop() + return value + + errors: list[str] = [] + with mock.patch.object(VALIDATOR, "load_yaml", side_effect=load_without_one_invariant): + VALIDATOR.validate_catalogs(errors) + self.assertTrue(any("closed invariant inventory" in error for error in errors), errors) + + def test_security_inventory_rejects_empty_required_evidence(self) -> None: + original = VALIDATOR.load_yaml + + def load_with_empty_fields(path: Path): + value = copy.deepcopy(original(path)) + if path.name == "security-invariant-matrix.yaml": + value["invariants"][0]["threat"] = "" + value["invariants"][0]["enforcementPoint"] = "" + value["invariants"][0]["expected"] = "" + value["invariants"][0]["evidence"] = "" + return value + + errors: list[str] = [] + with mock.patch.object(VALIDATOR, "load_yaml", side_effect=load_with_empty_fields): + VALIDATOR.validate_catalogs(errors) + for field in ("threat", "enforcementPoint", "expected", "evidence"): + self.assertTrue(any(f".{field}:" in error for error in errors), errors) + + def test_security_inventory_requires_an_exact_negative_test(self) -> None: + original = VALIDATOR.load_yaml + + def load_with_unknown_negative(path: Path): + value = copy.deepcopy(original(path)) + if path.name == "security-invariant-matrix.yaml": + value["invariants"][0]["negativeTest"] = "not_a_listed_test" + return value + + errors: list[str] = [] + with mock.patch.object(VALIDATOR, "load_yaml", side_effect=load_with_unknown_negative): + VALIDATOR.validate_catalogs(errors) + self.assertTrue(any("select one exact listed negative test" in error for error in errors), errors) + if __name__ == "__main__": unittest.main() diff --git a/products/relay-v2/scripts/validate_product.py b/products/relay-v2/scripts/validate_product.py index 09939ee7a..c2e7a99f6 100644 --- a/products/relay-v2/scripts/validate_product.py +++ b/products/relay-v2/scripts/validate_product.py @@ -29,6 +29,30 @@ "unexpected-value", "excessive-size", } +SECURITY_INVARIANT_IDS = { + "sec-contract-runtime-separation", + "sec-package-activation-integrity", + "sec-one-registry-boundary", + "sec-sqlite-read-only", + "sec-sqlite-connection-recovery", + "sec-token-profile-closed", + "sec-resource-existence-concealment", + "sec-operation-confinement", + "sec-operation-quota", + "sec-trusted-context", + "sec-disclosure-monotonic", + "sec-lookup-non-enumeration", + "sec-malformed-row-atomicity", + "sec-reference-visibility", + "sec-audit-release-gate", + "sec-audit-correlation-and-minimization", + "sec-cursor-integrity", + "sec-source-truthfulness", + "sec-value-free-diagnostics", + "sec-value-free-operational-logs", + "sec-value-free-trace-context", + "sec-unsigned-family-boundary", +} SIMPLE_IDENTIFIER = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") SHA256 = re.compile(r"^sha256:[0-9a-f]{64}$") RUST_TEST = re.compile( @@ -93,8 +117,8 @@ def executable_test_resolves(reference: Any, label: str, errors: list[str]) -> N errors.append(f"{label}: exact executable test does not resolve: {raw_path}::{name}") -def journey_steps(errors: list[str]) -> dict[str, set[str]]: - result: dict[str, set[str]] = {} +def journey_steps(errors: list[str]) -> dict[str, dict[str, tuple[Any, Any]]]: + result: dict[str, dict[str, tuple[Any, Any]]] = {} for project_name in PROJECTS: project = PRODUCT_ROOT / "acceptance" / project_name for required in ( @@ -110,7 +134,7 @@ def journey_steps(errors: list[str]) -> dict[str, set[str]]: authorizations = mapping( journey.get("authorizations"), f"{project_name} journey authorizations", errors ) - identifiers: set[str] = set() + identifiers: dict[str, tuple[Any, Any]] = {} for index, raw in enumerate( sequence(journey.get("steps"), f"{project_name} journey steps", errors) ): @@ -119,7 +143,10 @@ def journey_steps(errors: list[str]) -> dict[str, set[str]]: if not isinstance(identifier, str) or not identifier or identifier in identifiers: errors.append(f"{project_name}: journey step ids must be unique and non-empty") continue - identifiers.add(identifier) + expectation = mapping( + step.get("expect"), f"{project_name} journey step[{index}].expect", errors + ) + identifiers[identifier] = (expectation.get("status"), expectation.get("code")) authorization = step.get("authorizationFixture") if authorization is not None and authorization not in authorizations: errors.append( @@ -372,7 +399,7 @@ def validate_catalogs(errors: list[str]) -> None: scenario = mapping(raw, f"scenario[{index}]", errors) expected_keys = {"id", "project", "journeyStep", "assertion"} if "invalidSourceRowClass" in scenario: - expected_keys.add("invalidSourceRowClass") + expected_keys.update({"invalidSourceRowClass", "expectedStatus", "expectedCode"}) require_exact_keys(scenario, expected_keys, f"scenario[{index}]", errors) identifier = scenario.get("id") project = scenario.get("project") @@ -381,7 +408,7 @@ def validate_catalogs(errors: list[str]) -> None: errors.append(f"scenario[{index}]: id must be unique and non-empty") else: scenario_ids.add(identifier) - if project not in steps or step not in steps.get(project, set()): + if project not in steps or step not in steps.get(project, {}): errors.append(f"scenario[{index}]: does not resolve to an exact journey step") elif isinstance(step, str): covered[project].add(step) @@ -391,8 +418,24 @@ def validate_catalogs(errors: list[str]) -> None: errors.append(f"scenario[{index}]: unknown invalid source-row class") elif project in invalid_classes: invalid_classes[project].add(invalid_class) + if ( + scenario.get("expectedStatus") != 503 + or scenario.get("expectedCode") != "source.unavailable" + ): + errors.append( + f"scenario[{index}]: an invalid source row must expect 503 source.unavailable" + ) + if project in steps and isinstance(step, str): + journey_status, journey_code = steps[project].get(step, (None, None)) + if ( + scenario.get("expectedStatus"), + scenario.get("expectedCode"), + ) != (journey_status, journey_code): + errors.append( + f"scenario[{index}]: invalid source-row expectation disagrees with the journey" + ) for project in PROJECTS: - if covered[project] != steps[project]: + if covered[project] != set(steps[project]): errors.append(f"scenario matrix: {project} journey coverage is not exact") if not invalid_classes[project]: errors.append(f"{project}: at least one invalid source-row refusal is required") @@ -408,6 +451,16 @@ def validate_catalogs(errors: list[str]) -> None: "security invariant matrix", errors, ) + require_exact_keys( + matrix, + {"schemaVersion", "product", "status", "invariants"}, + "security invariant matrix", + errors, + ) + if matrix.get("schemaVersion") != "relay.registrystack.org/security-invariants/v1alpha1": + errors.append("security invariant matrix: schemaVersion is not supported") + if matrix.get("product") != "relay-v2" or matrix.get("status") != "enforced": + errors.append("security invariant matrix: product and enforced status are fixed") invariant_ids: set[str] = set() for index, raw in enumerate( sequence(matrix.get("invariants"), "security invariants", errors) @@ -419,9 +472,9 @@ def validate_catalogs(errors: list[str]) -> None: "id", "threat", "enforcementPoint", - "negativeCase", "expected", "evidence", + "negativeTest", "tests", }, f"security invariant[{index}]", @@ -432,17 +485,35 @@ def validate_catalogs(errors: list[str]) -> None: errors.append(f"security invariant[{index}]: id must be unique and non-empty") else: invariant_ids.add(identifier) + for field in ("threat", "enforcementPoint", "expected", "evidence"): + value = invariant.get(field) + if not isinstance(value, str) or not value.strip(): + errors.append( + f"security invariant[{index}].{field}: a non-empty value is required" + ) tests = sequence(invariant.get("tests"), f"security invariant[{index}].tests", errors) if not tests: errors.append(f"security invariant[{index}]: exact executable tests are required") + test_names: set[str] = set() for test_index, test in enumerate(tests): executable_test_resolves( test, f"security invariant[{index}].tests[{test_index}]", errors ) + if isinstance(test, dict) and isinstance(test.get("name"), str): + test_names.add(test["name"]) + negative_test = invariant.get("negativeTest") + if ( + not isinstance(negative_test, str) + or not SIMPLE_IDENTIFIER.fullmatch(negative_test) + or negative_test not in test_names + ): + errors.append( + f"security invariant[{index}].negativeTest: must select one exact listed negative test" + ) if any(str(value).strip().lower() in {"todo", "tbd"} for value in invariant.values()): errors.append(f"security invariant[{index}]: placeholder value is prohibited") - if len(invariant_ids) < 10: - errors.append("security invariant matrix: expected at least ten concrete invariants") + if invariant_ids != SECURITY_INVARIANT_IDS: + errors.append("security invariant matrix: the closed invariant inventory is incomplete") baselines = mapping( load_yaml(PRODUCT_ROOT / "contracts/generated-baselines.yaml"), From 294c19683acb41a106a268af73e0dfc377dd34e9 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 10 Aug 2026 13:23:27 +0700 Subject: [PATCH 08/24] docs(relay): keep Relay guidance additive Signed-off-by: Jeremi Joslin --- docs/site/astro.config.mjs | 24 +++++-- .../scripts/information-architecture.test.mjs | 26 +++++--- .../relay-semantics-and-disclosure.mdx | 27 ++++---- docs/site/src/content/docs/index.mdx | 63 ++++++++++++------- .../src/content/docs/start/quickstart.mdx | 56 +++++++++++------ .../src/content/docs/start/when-to-use.mdx | 6 +- 6 files changed, 130 insertions(+), 72 deletions(-) diff --git a/docs/site/astro.config.mjs b/docs/site/astro.config.mjs index 35a59d079..71556eaaf 100644 --- a/docs/site/astro.config.mjs +++ b/docs/site/astro.config.mjs @@ -360,11 +360,25 @@ export default defineConfig({ { label: 'Connect an existing registry', items: [ - { label: 'Overview', slug: 'explanation/governed-registry-publication' }, - { label: 'Publish a SQLite registry', slug: 'tutorials/publish-governed-sqlite-registry' }, - { label: 'Semantics and disclosure', slug: 'explanation/relay-semantics-and-disclosure' }, - { label: 'Author a Relay project', slug: 'configure/relay' }, - { label: 'Operate Relay', slug: 'operate/relay' }, + { label: 'Overview', slug: 'configure' }, + { label: 'Start a spreadsheet registry', slug: 'tutorials/publish-spreadsheet-secured-registry-api' }, + { label: 'Use your own spreadsheet', slug: 'tutorials/use-your-spreadsheet' }, + { label: 'Connect an HTTP registry', slug: 'tutorials/author-registry-project' }, + { label: 'Configure OAuth client credentials', slug: 'configure/oauth-client-credentials' }, + { label: 'Add OAuth-backed Rhai', slug: 'tutorials/configure-project-script-adapter' }, + { label: 'Advanced source patterns', slug: 'explanation/integration-patterns' }, + { label: 'Configuration fields', slug: 'reference/project-configuration' }, + { + label: 'Relay V2 preview', + collapsed: true, + items: [ + { label: 'Overview', slug: 'explanation/governed-registry-publication' }, + { label: 'Publish a SQLite registry', slug: 'tutorials/publish-governed-sqlite-registry' }, + { label: 'Semantics and disclosure', slug: 'explanation/relay-semantics-and-disclosure' }, + { label: 'Author a Relay V2 project', slug: 'configure/relay' }, + { label: 'Operate Relay V2', slug: 'operate/relay' }, + ], + }, ], }, { diff --git a/docs/site/scripts/information-architecture.test.mjs b/docs/site/scripts/information-architecture.test.mjs index 375a58b9a..5761926ac 100644 --- a/docs/site/scripts/information-architecture.test.mjs +++ b/docs/site/scripts/information-architecture.test.mjs @@ -64,7 +64,7 @@ test('publishes one overview route for every task-flow section', () => { for (const [label, route] of [ ['Start', "link: '/'"], ['Answer with Evidence Gateway', "slug: 'start/evidence-quickstart'"], - ['Connect an existing registry', "slug: 'explanation/governed-registry-publication'"], + ['Connect an existing registry', "slug: 'configure'"], ['Operate', "slug: 'operate'"], ['Security', "slug: 'security'"], ['Reference', "slug: 'reference'"], @@ -75,13 +75,25 @@ test('publishes one overview route for every task-flow section', () => { } }); -test('keeps the compact Relay V2 reader journey under existing registries', () => { +test('keeps maintained Relay routes and adds the Relay V2 preview', () => { const start = topLevelSection(sidebarSource, 'Start'); assert.doesNotMatch( start, /slug: 'tutorials\//, ); const connect = topLevelSection(sidebarSource, 'Connect an existing registry'); + assert.match( + connect, + /label: 'Start a spreadsheet registry', slug: 'tutorials\/publish-spreadsheet-secured-registry-api'/, + ); + assert.match( + connect, + /label: 'Use your own spreadsheet', slug: 'tutorials\/use-your-spreadsheet'/, + ); + assert.match( + connect, + /label: 'Connect an HTTP registry', slug: 'tutorials\/author-registry-project'/, + ); assertOrdered( connect, [ @@ -93,18 +105,18 @@ test('keeps the compact Relay V2 reader journey under existing registries', () = ], 'Relay V2 reader journey', ); - assert.doesNotMatch(connect, /registryctl|author-registry-project|publish-spreadsheet/); + assert.match(connect, /label: 'Relay V2 preview'/); assert.doesNotMatch(connect, /verify-opencrvs-claims/); assert.match( homepageSource, - /\]\(tutorials\/publish-governed-sqlite-registry\/\)/, + /\]\(tutorials\/publish-spreadsheet-secured-registry-api\/\)/, ); assert.match( quickstartSource, - /\]\(\.\.\/\.\.\/tutorials\/publish-governed-sqlite-registry\/\)/, + /\]\(\.\.\/\.\.\/tutorials\/publish-spreadsheet-secured-registry-api\/\)/, ); - assert.match(homepageSource, /\]\(configure\/relay\/\)/); - assert.match(quickstartSource, /\]\(\.\.\/\.\.\/configure\/relay\/\)/); + assert.match(homepageSource, /\]\(tutorials\/author-registry-project\/\)/); + assert.match(quickstartSource, /\]\(\.\.\/\.\.\/tutorials\/author-registry-project\/\)/); assert.doesNotMatch(homepageSource, /tutorials\/verify-claim-registry-api/); assert.doesNotMatch(quickstartSource, /tutorials\/verify-claim-registry-api/); }); diff --git a/docs/site/src/content/docs/explanation/relay-semantics-and-disclosure.mdx b/docs/site/src/content/docs/explanation/relay-semantics-and-disclosure.mdx index f2ce93485..af6dde766 100644 --- a/docs/site/src/content/docs/explanation/relay-semantics-and-disclosure.mdx +++ b/docs/site/src/content/docs/explanation/relay-semantics-and-disclosure.mdx @@ -96,26 +96,23 @@ When absent, Relay uses the declared default. When present, Relay authorizes that exact representation: an invalid bearer, denied request, or unknown name does not fall back to another representation. After selection, `fields` may request only a non-empty subset of that representation's properties. -It cannot select a source column, switch profiles, change a transformation, bypass a row boundary, +It cannot select a source column, switch profiles, bypass a row boundary, or lower the compiled handling, audit, quota, metadata, or cache controls. This is requester minimization, not dynamic per-request masking or attribute authorization. Relay has no free-form policy engine or arbitrary expression language. -## Apply only bounded transforms - -The transform set is deliberately finite and deterministic. -`partial-string` produces the Relay-owned marker `***` with a bounded Unicode-scalar prefix or -suffix. -If the input has no more characters than the configured reveal length, the output is `***` alone. -`date-precision` turns a canonical `date` or `date-time` into a `year` or `year-month` property. -The transformed property has its own semantic term, output type, and classification. - -Invalid, noncanonical, oversized, or incompatible required input fails without exposing source -values. -Optional null input omits the property. -Relay does not provide hashing, pseudonyms, encryption, regular-expression replacement, geographic -or numeric transformations, codelist remapping, caller-defined masks, or dynamic masking policy. +The operator-only full JSON Schema and SHACL shape validate every +`sourceRequired` property and mandatory Record-context binding in the reviewed +view. Relay does not coerce or partially release an invalid selected row. For a +sensitive exact lookup, a missing Record, ambiguous result, and hidden row share +the same unresolved response so the result does not reveal which condition +occurred. An invalid selected source Record fails closed as +`503 source.unavailable` without releasing any part of the Record. + +Relay performs no runtime masking or value transformation. When a Registry +needs a masked, generalized, or precision-reduced value, its reviewed source +view must expose that pre-derived value as a separately classified property. ## Generate semantics without pretending equivalence diff --git a/docs/site/src/content/docs/index.mdx b/docs/site/src/content/docs/index.mdx index 74f3b10c0..ba7432002 100644 --- a/docs/site/src/content/docs/index.mdx +++ b/docs/site/src/content/docs/index.mdx @@ -1,19 +1,21 @@ --- title: Registry Stack documentation -description: Answer a bounded question with Evidence Gateway or publish a governed read-only Registry from SQLite with Registry Relay. +description: Answer a bounded question with Evidence Gateway, start a registry from a spreadsheet, or connect an existing registry. status: current owner: registry-docs source_repos: - registry-stack -last_reviewed: "2026-08-10" + - registry-relay +last_reviewed: "2026-08-03" doc_type: explanation locale: en standards_referenced: [] --- -Registry Stack gives an institution two distinct ways to use data it already -holds. Evidence Gateway signs the answer to one bounded question. Registry -Relay publishes governed Registry resources through a controlled read-only API. +Registry Stack helps an institution answer questions about data it already +holds without giving callers direct access to the source. Two doors: Evidence Gateway +signs the answer to one bounded question, and Registry Relay exposes selected +records through a protected read-only API. ## Answer a bounded question with Evidence Gateway @@ -32,32 +34,49 @@ boundary in one verified request. When local authoring is complete, reviewed candidate without promoting local development state. Registry Mint remains an optional token issuer for deployments without a suitable identity provider. -## Publish a governed Registry +## Start a registry from a spreadsheet -Use Registry Relay when an authorized caller needs selected Records rather -than a signed answer. Relay binds one institution-owned Registry contract to -reviewed SQLite views, then compiles the API, disclosure rules, semantic -artifacts, access decisions, source provenance, and audit behavior together. +Use the maintained spreadsheet path when the institution has a workbook or +can prepare a reviewed workbook derivative. +The first run starts Registry Relay over the shipped synthetic workbook. It +records a live authorization denial and a selected-field response; the offline +derived fixture supplies the zero-source-access evidence that a local file +cannot count itself. -[Publish a governed SQLite registry](tutorials/publish-governed-sqlite-registry/) -runs one synthetic business Registry from source through a sealed deployment -package and a real HTTP request. Continue with -[Relay project authoring](configure/relay/) for an institution-owned source and -[Relay operations](operate/relay/) for authentication, audit, and source-profile -choices. +[Start a registry from a spreadsheet](tutorials/publish-spreadsheet-secured-registry-api/) +takes about 20 minutes and uses released artifacts without a source checkout. +After it works, [use your own spreadsheet](tutorials/use-your-spreadsheet/). + +## Connect an existing registry + +Use the HTTP path when the institution already operates a registry API. +Start with a fixed bounded request and synthetic observations, then bind the +reviewed source endpoint and its credentials. + +[Connect an existing HTTP registry](tutorials/author-registry-project/) covers +the base integration. +Continue with [OAuth client credentials](configure/oauth-client-credentials/) +and a [reviewed Rhai adapter](tutorials/configure-project-script-adapter/) when +the source requires authentication or response normalization. ## Keep the product boundaries clear -Registry Relay owns governed read-only Registry resources. +Registry Relay owns source access and protected record surfaces. Evidence Gateway owns bounded question answering, signing, and minimum disclosure, and -runs independently against its own configured authoritative source. Registry -Mint remains an optional token issuer, not a Relay runtime dependency. A future -Evidence deployment can use a fixed Relay lookup as an ordinary HTTP source -without moving signing into Relay. +runs independently of Relay against its own configured authoritative HTTP sources. +The caller receives only the output authorized for that service. + +Both registryctl paths, spreadsheet and HTTP, use the same authoring, offline +test, disposable development, and build commands. The 1.0 project-local +workbook path stops there. A governed deployment starts after an +operator-managed HTTP source is bound, then continues through independent +approval and package generation. Evidence Gateway is not one of those paths: Evidence Gateway +has its own toolset and deployment project shape, covered by the +[Evidence Gateway overview](start/evidence-quickstart/). ## Move beyond the first run -- [Understand Registry Relay](explanation/governed-registry-publication/) +- [Choose a source path](configure/) - [Prepare an operator handoff](operate/) - [Review the architecture](explanation/architecture/) - [Review security boundaries](security/) diff --git a/docs/site/src/content/docs/start/quickstart.mdx b/docs/site/src/content/docs/start/quickstart.mdx index 0903e8353..92fec9724 100644 --- a/docs/site/src/content/docs/start/quickstart.mdx +++ b/docs/site/src/content/docs/start/quickstart.mdx @@ -1,20 +1,22 @@ --- -title: Start with Registry Stack -description: Answer a bounded question with Evidence Gateway or publish governed Registry resources from SQLite with Registry Relay. +title: Start with Registry Stack 1.0 +description: Answer a bounded question with Evidence Gateway, or publish protected records with Registry Relay, then adapt the source or the definition. status: current owner: registry-docs source_repos: - registry-stack -last_reviewed: "2026-08-10" + - registry-relay +last_reviewed: "2026-08-03" doc_type: explanation locale: en standards_referenced: [] --- -Pick the door that matches what the caller needs. To learn only a fact about -one subject, start with Evidence Gateway. To read selected Registry Records, -start with Registry Relay over the maintained SQLite business Registry. Both -first runs use synthetic data and stay on the local machine. +Pick the door that matches what your caller needs. To learn only a fact about +one subject, start with Evidence Gateway. To read specific records or fields, start +with Registry Relay over the maintained spreadsheet registry. Both first runs +use one terminal and synthetic data. Neither needs a source checkout, +production keys, or a deployment package. ## Answer a bounded question with Evidence Gateway @@ -26,25 +28,39 @@ verification, and audit boundaries. then connects a visible Python registry, sends a real request, and verifies the minimum answer before reading the answer. -## Publish a governed Registry +## Start from a spreadsheet -[Publish a governed SQLite registry](../../tutorials/publish-governed-sqlite-registry/) -builds `relay` and `relayctl`, compiles one synthetic business Registry, -generates its semantic and API artifacts, runs its fixtures, seals a package, -and reads selected properties through the real HTTP service. +[Start a registry from a spreadsheet](../../tutorials/publish-spreadsheet-secured-registry-api/) +creates the released `spreadsheet` project, checks its source behavior offline, +and runs Relay locally over maintained synthetic records. -Continue with [Relay project authoring](../../configure/relay/) when the sample -works. The guide covers reviewed SQLite views, Registry Core bindings, -Consultation operations, classification, semantics, disclosure, fixtures, and -change review. +Take this path when the institution has a workbook, or can prepare a reviewed +workbook derivative. +Continue with [your own spreadsheet](../../tutorials/use-your-spreadsheet/) +after the sample works. + +## Connect an existing registry + +[Connect an existing HTTP registry](../../tutorials/author-registry-project/) +creates the released `http` project and tests one bounded source request before +an institution-owned endpoint is introduced. + +Continue with: + +- [OAuth client credentials](../../configure/oauth-client-credentials/) when + the source requires a bearer token +- [OAuth-backed Rhai](../../tutorials/configure-project-script-adapter/) when a + reviewed mapping needs several bounded same-origin requests or response + normalization +- [The OpenCRVS Events API case study](../../tutorials/verify-opencrvs-claims/) + for a synthetic example of that generic integration path ## Keep the two doors separate An institution can operate Registry Relay and Evidence Gateway, but they are independent products with separate sources, authorization, configuration, and -audit boundaries. Registry Mint can issue Relay access tokens when no suitable -authorization server exists, but Relay does not depend on Mint at runtime. +audit boundaries. Evidence Gateway does not use Relay as its source path. Choose the tutorial for the result you need. Start with the Evidence Gateway -tutorial for a signed, minimum-disclosure answer, or use the Relay tutorial for -a governed read-only Registry API. +tutorial for a signed, minimum-disclosure answer, or use the spreadsheet and +HTTP tutorials for a protected record API. diff --git a/docs/site/src/content/docs/start/when-to-use.mdx b/docs/site/src/content/docs/start/when-to-use.mdx index b8d4f3889..b887fd7a5 100644 --- a/docs/site/src/content/docs/start/when-to-use.mdx +++ b/docs/site/src/content/docs/start/when-to-use.mdx @@ -5,7 +5,7 @@ status: current owner: registry-docs source_repos: - registry-stack -last_reviewed: "2026-08-10" +last_reviewed: "2026-08-03" doc_type: explanation locale: en standards_referenced: [] @@ -77,8 +77,8 @@ approval. ## Next -- [Publish a governed SQLite registry](../../tutorials/publish-governed-sqlite-registry/) -- [Author a Registry Relay project](../../configure/relay/) +- [Start a registry from a spreadsheet](../../tutorials/publish-spreadsheet-secured-registry-api/) +- [Connect an existing HTTP registry](../../tutorials/author-registry-project/) - [Evaluate Evidence Gateway](../evaluate-evidence/) - [Read the architecture overview](../../explanation/architecture/) - [Review the security boundaries](../../security/) From 5fa600ab1e47bf62f5957a3bba040a6f03193ae5 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 10 Aug 2026 13:23:28 +0700 Subject: [PATCH 09/24] chore(release): defer Relay V2 image publication Signed-off-by: Jeremi Joslin --- .github/workflows/ci.yml | 3 -- release/docker/Dockerfile.relay | 35 ------------- release/scripts/check-debian13-images.py | 25 --------- release/scripts/check-gates-inventory.py | 9 ++++ release/scripts/test_check_debian13_images.py | 52 ------------------- release/scripts/test_check_gates_inventory.py | 22 ++++++++ 6 files changed, 31 insertions(+), 115 deletions(-) delete mode 100644 release/docker/Dockerfile.relay delete mode 100644 release/scripts/test_check_debian13_images.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 46cfd07d7..b8f9ce188 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -705,9 +705,6 @@ jobs: - name: Test release workflow structure run: python3 -m unittest release/scripts/test_release_workflow_structure.py - - name: Test maintained Debian 13 image checks - run: python3 -m unittest release/scripts/test_check_debian13_images.py - - name: Test release workflow guard run: python3 -m unittest release/scripts/test_release_workflow_guard.py diff --git a/release/docker/Dockerfile.relay b/release/docker/Dockerfile.relay deleted file mode 100644 index d9fe0e261..000000000 --- a/release/docker/Dockerfile.relay +++ /dev/null @@ -1,35 +0,0 @@ -# syntax=docker/dockerfile:1.7@sha256:a57df69d0ea827fb7266491f2813635de6f17269be881f696fbfdf2d83dda33e - -ARG SOURCE_DATE_EPOCH=0 - -FROM debian:trixie-slim@sha256:020c0d20b9880058cbe785a9db107156c3c75c2ac944a6aa7ab59f2add76a7bd AS runtime-root -ARG SOURCE_DATE_EPOCH - -RUN --mount=type=bind,source=dist/image-bin,target=/workspace/image-bin \ - --mount=type=bind,source=LICENSE,target=/workspace/LICENSE \ - mkdir -p \ - /workspace/runtime-root/etc/relay \ - /workspace/runtime-root/licenses/relay \ - /workspace/runtime-root/usr/local/bin \ - /workspace/runtime-root/var/lib/relay/audit \ - /workspace/runtime-root/var/lib/relay/data \ - && install -m 0755 /workspace/image-bin/relay /workspace/runtime-root/usr/local/bin/relay \ - && install -m 0644 /workspace/LICENSE /workspace/runtime-root/licenses/relay/LICENSE \ - && chown -R 65532:65532 \ - /workspace/runtime-root/etc/relay \ - /workspace/runtime-root/var/lib/relay \ - && chmod 0700 /workspace/runtime-root/var/lib/relay/audit \ - && find /workspace/runtime-root -exec touch -h --date="@${SOURCE_DATE_EPOCH}" {} + - -FROM gcr.io/distroless/cc-debian13:nonroot@sha256:d97bc0a941b8d4be647dc0ee75b264ddbb772f1ac5ba690a4309c00723b23775 AS runtime - -COPY --from=runtime-root /workspace/runtime-root/ / - -WORKDIR /var/lib/relay - -EXPOSE 8080 - -HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 CMD ["/usr/local/bin/relay", "healthcheck", "--url", "http://127.0.0.1:8080/health"] - -ENTRYPOINT ["/usr/local/bin/relay"] -CMD ["serve", "--runtime", "/etc/relay/runtime.yaml"] diff --git a/release/scripts/check-debian13-images.py b/release/scripts/check-debian13-images.py index 362435229..f501d20ae 100755 --- a/release/scripts/check-debian13-images.py +++ b/release/scripts/check-debian13-images.py @@ -36,7 +36,6 @@ Path("crates/registry-relay/Dockerfile"), Path("crates/registry-relay/Dockerfile.demo"), Path("release/docker/Dockerfile.registry-relay"), - Path("release/docker/Dockerfile.relay"), ) # Adopter and development images. They build from source like the per-product @@ -66,7 +65,6 @@ Path("crates/registry-relay/Dockerfile.demo"), Path("release/docker/Dockerfile.registry-relay"), ) -RELAY_V2_DOCKERFILES = (Path("release/docker/Dockerfile.relay"),) FROM_RE = re.compile(r"^FROM\s+(?:--platform=\S+\s+)?(\S+)", re.MULTILINE) STAGE_NAME_RE = re.compile(r"^FROM\s+\S+\s+AS\s+(\S+)", re.MULTILINE | re.IGNORECASE) @@ -283,29 +281,6 @@ def check_repository(root: Path = ROOT) -> list[str]: failures, ) - for relative in RELAY_V2_DOCKERFILES: - text = texts[relative] - require( - text, - "/usr/local/bin/relay", - relative, - "Relay V2 binary", - failures, - ) - require( - runtime_stage(text), - 'ENTRYPOINT ["/usr/local/bin/relay"]', - relative, - "absolute Relay V2 entrypoint", - failures, - ) - require( - runtime_stage(text), - 'CMD ["serve", "--runtime", "/etc/relay/runtime.yaml"]', - relative, - "absolute Relay V2 runtime configuration binding", - failures, - ) candidate_workflow = texts[Path(".github/workflows/release-candidate.yml")] release_workflow = texts[Path(".github/workflows/release.yml")] binary_recipe = texts[Path("release/scripts/build-release-binaries.sh")] diff --git a/release/scripts/check-gates-inventory.py b/release/scripts/check-gates-inventory.py index 216685e4c..3417bf61a 100644 --- a/release/scripts/check-gates-inventory.py +++ b/release/scripts/check-gates-inventory.py @@ -170,6 +170,15 @@ ("Relay OpenAPI contract", "name: Relay OpenAPI contract"), ("Relay OpenAPI command", "run: just openapi-contract"), ("Relay exposure check", "name: Relay exposure check"), + ("Relay V2 product contract gate", "relay-v2-contracts:"), + ( + "Relay V2 contract consistency", + "run: products/relay-v2/scripts/check-contracts.sh", + ), + ( + "Relay V2 coequal HTTP journeys", + "run: products/relay-v2/scripts/test-http.sh", + ), ( "Release helper tests", "run: python3 -m unittest release/scripts/test_registry_release.py", diff --git a/release/scripts/test_check_debian13_images.py b/release/scripts/test_check_debian13_images.py deleted file mode 100644 index 715f97363..000000000 --- a/release/scripts/test_check_debian13_images.py +++ /dev/null @@ -1,52 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -"""Regression tests for the maintained Debian 13 image policy.""" - -from __future__ import annotations - -import importlib.util -import tempfile -import unittest -from pathlib import Path - - -SCRIPT = Path(__file__).with_name("check-debian13-images.py") -SPEC = importlib.util.spec_from_file_location("check_debian13_images", SCRIPT) -assert SPEC and SPEC.loader -POLICY = importlib.util.module_from_spec(SPEC) -SPEC.loader.exec_module(POLICY) - - -class RelayV2ImagePolicyTests(unittest.TestCase): - def repository_copy(self, root: Path) -> None: - for relative in POLICY.MAINTAINED_TEXT_PATHS: - target = root / relative - target.parent.mkdir(parents=True, exist_ok=True) - target.write_bytes(POLICY.ROOT.joinpath(relative).read_bytes()) - - def test_relay_v2_image_is_a_required_maintained_surface(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - self.repository_copy(root) - dockerfile = root / "release/docker/Dockerfile.relay" - dockerfile.write_text( - dockerfile.read_text(encoding="utf-8").replace( - 'ENTRYPOINT ["/usr/local/bin/relay"]', - 'ENTRYPOINT ["relay"]', - ), - encoding="utf-8", - ) - - failures = POLICY.check_repository(root) - - self.assertTrue( - any( - "Dockerfile.relay" in failure - and "absolute Relay V2 entrypoint" in failure - for failure in failures - ), - failures, - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/release/scripts/test_check_gates_inventory.py b/release/scripts/test_check_gates_inventory.py index a8d09a82d..aef743ba6 100644 --- a/release/scripts/test_check_gates_inventory.py +++ b/release/scripts/test_check_gates_inventory.py @@ -490,6 +490,28 @@ def test_missing_relay_exposure_gate_is_reported(self) -> None: ) self.assertIn("Relay exposure check", self.module.missing_gates(text)) + def test_missing_relay_v2_product_gates_are_reported(self) -> None: + for snippet, replacement, gate in ( + ( + "relay-v2-contracts:", + "relay-v2-disabled:", + "Relay V2 product contract gate", + ), + ( + "run: products/relay-v2/scripts/check-contracts.sh", + "run: true # Relay V2 contracts disabled", + "Relay V2 contract consistency", + ), + ( + "run: products/relay-v2/scripts/test-http.sh", + "run: true # Relay V2 HTTP disabled", + "Relay V2 coequal HTTP journeys", + ), + ): + with self.subTest(gate=gate): + text = self.workflow.replace(snippet, replacement) + self.assertIn(gate, self.module.missing_gates(text)) + def test_missing_debian13_image_contract_is_reported(self) -> None: text = self.workflow.replace( "run: python3 release/scripts/check-debian13-images.py", From 99a59433cb947a0aced1030e4f57af1939e14cf4 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 10 Aug 2026 15:26:00 +0700 Subject: [PATCH 10/24] fix(platform): harden SQLite snapshot truthfulness Signed-off-by: Jeremi Joslin --- .../registry-platform-sqlite/src/capture.rs | 44 +++++++++- .../registry-platform-sqlite/src/statement.rs | 86 +++++++++++++++++-- .../registry-platform-sqlite/tests/kernel.rs | 83 ++++++++++++++++++ 3 files changed, 204 insertions(+), 9 deletions(-) diff --git a/crates/registry-platform-sqlite/src/capture.rs b/crates/registry-platform-sqlite/src/capture.rs index 8959b595a..29e978393 100644 --- a/crates/registry-platform-sqlite/src/capture.rs +++ b/crates/registry-platform-sqlite/src/capture.rs @@ -1,6 +1,7 @@ use std::fs::{self, File, Metadata}; use std::io::Read as _; use std::path::{Path, PathBuf}; +use std::time::Instant; use sha2::{Digest as _, Sha256}; @@ -43,7 +44,7 @@ impl CapturedSnapshot { return Err(SqliteError::new(ErrorKind::DatabaseWritable)); } refuse_sidecars(path)?; - let (digest, identity) = digest_stable(path, &scanned, filesystem_read_only)?; + let (digest, identity) = digest_stable(path, &scanned, filesystem_read_only, None)?; refuse_sidecars(path)?; Ok(Self { path: path.to_path_buf(), @@ -80,12 +81,37 @@ impl CapturedSnapshot { /// I/O is acceptable and identity checks alone are not a sufficient proof /// that an immutable deployment input has not drifted. pub fn verify_unchanged(&self) -> Result<(), SqliteError> { - self.confirm_still_bound()?; + self.verify_unchanged_until(None) + } + + pub(crate) fn verify_unchanged_before(&self, deadline: Instant) -> Result<(), SqliteError> { + self.verify_unchanged_until(Some(deadline)) + } + + // Per-read verification closes drift between readiness probes. A process + // cannot exclude a privileged writer changing and restoring bytes entirely + // between the two hashes, so snapshot deployments still require the + // captured file to be immutable outside this process, preferably through a + // read-only mount. + fn verify_unchanged_until(&self, deadline: Option) -> Result<(), SqliteError> { + ensure_before_deadline(deadline)?; + refuse_sidecars(&self.path)?; let scanned = fs::symlink_metadata(&self.path) .map_err(|_| SqliteError::new(ErrorKind::DatabaseUnavailable))?; + if scanned.file_type().is_symlink() + || !scanned.is_file() + || !same_live_file(&self.identity.0, &scanned) + { + return Err(SqliteError::new(ErrorKind::DatabaseReplaced)); + } let filesystem_read_only = filesystem_read_only(&self.path)?; - let (digest, identity) = digest_stable(&self.path, &scanned, filesystem_read_only)?; + let (digest, identity) = + digest_stable(&self.path, &scanned, filesystem_read_only, deadline)?; refuse_sidecars(&self.path)?; + ensure_before_deadline(deadline)?; + if !same_live_file(&self.identity.0, &identity.0) { + return Err(SqliteError::new(ErrorKind::DatabaseReplaced)); + } if identity != self.identity || digest != self.digest { return Err(SqliteError::new(ErrorKind::DatabaseChanged)); } @@ -153,7 +179,9 @@ fn digest_stable( path: &Path, scanned: &Metadata, fs_read_only: bool, + deadline: Option, ) -> Result<(String, FileIdentity), SqliteError> { + ensure_before_deadline(deadline)?; let mut file = open_no_follow(path)?; let opened = file .metadata() @@ -168,6 +196,7 @@ fn digest_stable( let mut buffer = [0_u8; DIGEST_CHUNK_BYTES]; let mut read_total = 0_u64; loop { + ensure_before_deadline(deadline)?; let read = file .read(&mut buffer) .map_err(|_| SqliteError::new(ErrorKind::DatabaseUnavailable))?; @@ -181,6 +210,7 @@ fn digest_stable( ) .ok_or_else(|| SqliteError::new(ErrorKind::DatabaseChanged))?; } + ensure_before_deadline(deadline)?; let after = file .metadata() .map_err(|_| SqliteError::new(ErrorKind::DatabaseUnavailable))?; @@ -193,6 +223,14 @@ fn digest_stable( )) } +fn ensure_before_deadline(deadline: Option) -> Result<(), SqliteError> { + if deadline.is_some_and(|value| Instant::now() >= value) { + Err(SqliteError::new(ErrorKind::TimeBudgetExceeded)) + } else { + Ok(()) + } +} + fn sha256_label(bytes: &[u8]) -> String { const HEX: &[u8; 16] = b"0123456789abcdef"; let mut label = String::with_capacity(7 + bytes.len() * 2); diff --git a/crates/registry-platform-sqlite/src/statement.rs b/crates/registry-platform-sqlite/src/statement.rs index 6c3dca808..232358325 100644 --- a/crates/registry-platform-sqlite/src/statement.rs +++ b/crates/registry-platform-sqlite/src/statement.rs @@ -211,6 +211,12 @@ impl DatabaseProfile { Self::LiveReadOnly(value) => value.confirm_still_bound(), } } + fn verify_execution_binding(&self, deadline: Instant) -> Result<(), SqliteError> { + match self { + Self::Snapshot(value) => value.verify_unchanged_before(deadline), + Self::LiveReadOnly(value) => value.confirm_still_bound(), + } + } } #[derive(Debug, Clone)] @@ -320,7 +326,6 @@ impl ReadOnlyStatement { &self, values: &BTreeMap, ) -> Result { - self.profile.confirm()?; let bindings = bind_values(&self.plan.parameters, values)?; let deadline = deadline(self.plan.limits.timeout)?; let async_deadline = tokio::time::Instant::from_std(deadline); @@ -341,7 +346,8 @@ impl ReadOnlyStatement { let pool = Arc::clone(&self.connections); let profile = self.profile.clone(); let execution = tokio::task::spawn_blocking(move || { - let execution = execute_on_connection(&connection, &plan, &bindings, deadline); + let execution = + execute_on_connection(&profile, &connection, &plan, &bindings, deadline); if return_or_replace_connection(&pool, &profile, connection, execution.reusable) { drop(permit); } else { @@ -355,7 +361,6 @@ impl ReadOnlyStatement { .await .map_err(|_| SqliteError::new(ErrorKind::Timeout))? .map_err(|_| SqliteError::new(ErrorKind::WorkerUnavailable))??; - self.profile.confirm()?; Ok(ResultSet { rows, provenance: self.provenance(schema_fingerprint), @@ -367,7 +372,6 @@ impl ReadOnlyStatement { &self, values: &BTreeMap, ) -> Result { - self.profile.confirm()?; let bindings = bind_values(&self.plan.parameters, values)?; let deadline = deadline(self.plan.limits.timeout)?; let connection = self @@ -376,7 +380,8 @@ impl ReadOnlyStatement { .unwrap_or_else(std::sync::PoisonError::into_inner) .pop() .ok_or_else(|| SqliteError::new(ErrorKind::WorkerUnavailable))?; - let execution = execute_on_connection(&connection, &self.plan, &bindings, deadline); + let execution = + execute_on_connection(&self.profile, &connection, &self.plan, &bindings, deadline); let restored = return_or_replace_connection( &self.connections, &self.profile, @@ -386,7 +391,6 @@ impl ReadOnlyStatement { if !restored { self.concurrency.forget_permits(1); } - self.profile.confirm()?; let (rows, schema_fingerprint) = execution.outcome?; Ok(ResultSet { rows, @@ -486,11 +490,36 @@ fn confirm_connection_pool_still_bound(connections: &[Connection]) -> Result<(), } fn execute_on_connection( + profile: &DatabaseProfile, + connection: &Connection, + plan: &CompiledPlan, + bindings: &[(usize, Value)], + deadline: Instant, +) -> ConnectionExecution<(Vec, Option)> { + execute_on_connection_with_post_statement_hook( + profile, + connection, + plan, + bindings, + deadline, + || {}, + ) +} + +fn execute_on_connection_with_post_statement_hook( + profile: &DatabaseProfile, connection: &Connection, plan: &CompiledPlan, bindings: &[(usize, Value)], deadline: Instant, + post_statement: impl FnOnce(), ) -> ConnectionExecution<(Vec, Option)> { + if let Err(error) = profile.verify_execution_binding(deadline) { + return ConnectionExecution { + outcome: Err(error), + reusable: false, + }; + } if let Err(error) = confirm_connection_still_bound(connection) { return ConnectionExecution { outcome: Err(error), @@ -498,6 +527,7 @@ fn execute_on_connection( }; } let mut execution = run_statement(connection, plan, bindings, deadline); + post_statement(); if execution.reusable { if let Err(error) = confirm_connection_still_bound(connection) { if execution.outcome.is_ok() { @@ -506,6 +536,10 @@ fn execute_on_connection( execution.reusable = false; } } + if let Err(error) = profile.verify_execution_binding(deadline) { + execution.outcome = Err(error); + execution.reusable = false; + } execution } @@ -1322,6 +1356,46 @@ mod tests { } } + #[cfg(unix)] + #[test] + fn snapshot_digest_is_rechecked_after_the_statement_finishes() { + use std::os::unix::fs::PermissionsExt as _; + + let temporary = tempfile::tempdir().expect("temporary root"); + let path = temporary.path().join("source.sqlite"); + database(&path, "governed"); + let profile = + DatabaseProfile::Snapshot(CapturedSnapshot::capture(&path).expect("snapshot captures")); + let connection = open_replacement_connection(&profile).expect("snapshot connection opens"); + let execution = execute_on_connection_with_post_statement_hook( + &profile, + &connection, + &test_plan("SELECT id FROM records", 100_000), + &[], + Instant::now() + Duration::from_secs(1), + || { + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)) + .expect("snapshot becomes writable for the hostile writer"); + let writer = Connection::open(&path).expect("hostile writer opens"); + writer + .execute("UPDATE records SET id = 'sensitive-row-value'", []) + .expect("hostile writer mutates the same inode"); + writer.close().expect("hostile writer closes"); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o400)) + .expect("snapshot permissions are restored"); + }, + ); + + assert_eq!( + execution + .outcome + .expect_err("post-statement mutation must suppress the rows") + .kind(), + ErrorKind::DatabaseChanged + ); + assert!(!execution.reusable); + } + #[test] fn step_deadline_engine_and_authorizer_failures_leave_connection_reusable() { let connection = reusable_test_connection(); diff --git a/crates/registry-platform-sqlite/tests/kernel.rs b/crates/registry-platform-sqlite/tests/kernel.rs index e3c7f29f4..2a7474476 100644 --- a/crates/registry-platform-sqlite/tests/kernel.rs +++ b/crates/registry-platform-sqlite/tests/kernel.rs @@ -27,6 +27,25 @@ fn make_writable(path: &std::path::Path) { fs::set_permissions(path, fs::Permissions::from_mode(0o600)).unwrap(); } +#[cfg(unix)] +fn mutate_same_inode_and_restore_read_only(path: &std::path::Path) { + use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _}; + + let before = fs::metadata(path).unwrap(); + make_writable(path); + let connection = Connection::open(path).unwrap(); + connection + .execute( + "UPDATE records SET id = 'sensitive-row-value' WHERE id = 'one'", + [], + ) + .unwrap(); + connection.close().unwrap(); + fs::set_permissions(path, fs::Permissions::from_mode(0o400)).unwrap(); + let after = fs::metadata(path).unwrap(); + assert_eq!((after.dev(), after.ino()), (before.dev(), before.ino())); +} + #[cfg(not(unix))] fn make_writable(path: &std::path::Path) { let mut permissions = fs::metadata(path).unwrap().permissions(); @@ -85,6 +104,70 @@ async fn a_snapshot_is_digest_bound_and_read_immutably() { assert!(rows.provenance.statement_digest.starts_with("sha256:")); } +#[cfg(unix)] +#[tokio::test] +async fn async_snapshot_execution_refuses_same_inode_content_drift() { + let directory = TempDir::new().unwrap(); + let path = database(&directory); + let snapshot = CapturedSnapshot::capture(&path).unwrap(); + let expected_revision = snapshot.digest().to_owned(); + let statement = ReadOnlyStatement::open( + DatabaseProfile::Snapshot(snapshot), + contract("SELECT id FROM records WHERE active = :active ORDER BY id"), + ) + .unwrap(); + let initial = statement + .execute(&BTreeMap::from([( + "active".to_owned(), + Value::Boolean(true), + )])) + .await + .unwrap(); + assert_eq!( + initial.provenance.source_revision.as_deref(), + Some(expected_revision.as_str()) + ); + + mutate_same_inode_and_restore_read_only(&path); + let error = statement + .execute(&BTreeMap::from([( + "active".to_owned(), + Value::Boolean(true), + )])) + .await + .expect_err("changed bytes must never be reported under the startup digest"); + assert_eq!(error.kind(), ErrorKind::DatabaseChanged); + assert!(!error.to_string().contains("sensitive-row-value")); + assert!(!error.to_string().contains(&expected_revision)); + assert!(!error.to_string().contains(path.to_string_lossy().as_ref())); +} + +#[cfg(unix)] +#[test] +fn startup_snapshot_execution_refuses_same_inode_content_drift() { + let directory = TempDir::new().unwrap(); + let path = database(&directory); + let snapshot = CapturedSnapshot::capture(&path).unwrap(); + let expected_revision = snapshot.digest().to_owned(); + let statement = ReadOnlyStatement::open( + DatabaseProfile::Snapshot(snapshot), + contract("SELECT id FROM records WHERE active = :active ORDER BY id"), + ) + .unwrap(); + + mutate_same_inode_and_restore_read_only(&path); + let error = statement + .execute_at_open(&BTreeMap::from([( + "active".to_owned(), + Value::Boolean(true), + )])) + .expect_err("changed bytes must never be reported under the startup digest"); + assert_eq!(error.kind(), ErrorKind::DatabaseChanged); + assert!(!error.to_string().contains("sensitive-row-value")); + assert!(!error.to_string().contains(&expected_revision)); + assert!(!error.to_string().contains(path.to_string_lossy().as_ref())); +} + #[test] fn every_mutating_or_connection_widening_action_is_refused() { let directory = TempDir::new().unwrap(); From 767f791a5f05c4d5ec474132f1b1322d169dbf46 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 10 Aug 2026 15:26:05 +0700 Subject: [PATCH 11/24] fix(relay): close governed representation contracts Signed-off-by: Jeremi Joslin --- Cargo.lock | 2 + crates/registry-relay-v2/Cargo.toml | 2 + crates/registry-relay-v2/src/api.rs | 186 ++++-- crates/registry-relay-v2/src/compiler.rs | 180 +++++- .../registry-relay-v2/src/fixture_contract.rs | 105 ++++ crates/registry-relay-v2/src/fixtures.rs | 285 ++++++--- crates/registry-relay-v2/src/lib.rs | 1 + crates/registry-relay-v2/src/package.rs | 374 ++++++++++- crates/registry-relay-v2/src/problem.rs | 8 +- crates/registry-relay-v2/src/semantics.rs | 79 ++- .../tests/acceptance_http.rs | 592 ++++++++++++++---- .../tests/representation_http.rs | 56 +- 12 files changed, 1498 insertions(+), 372 deletions(-) create mode 100644 crates/registry-relay-v2/src/fixture_contract.rs diff --git a/Cargo.lock b/Cargo.lock index a3372d5a1..7cac99985 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6284,7 +6284,9 @@ dependencies = [ "hex", "hmac 0.13.0", "http", + "jsonschema 0.18.3", "jsonwebtoken", + "oxjsonld", "registry-platform-audit", "registry-platform-authcommon", "registry-platform-buildinfo", diff --git a/crates/registry-relay-v2/Cargo.toml b/crates/registry-relay-v2/Cargo.toml index e7cf1254d..d86082fd1 100644 --- a/crates/registry-relay-v2/Cargo.toml +++ b/crates/registry-relay-v2/Cargo.toml @@ -60,6 +60,8 @@ zeroize.workspace = true [dev-dependencies] async-trait.workspace = true futures.workspace = true +jsonschema.workspace = true +oxjsonld = "0.2.5" registry-platform-httputil.workspace = true registry-platform-sqlite = { workspace = true, features = ["fixture"] } registry-platform-testing.workspace = true diff --git a/crates/registry-relay-v2/src/api.rs b/crates/registry-relay-v2/src/api.rs index f7c2b36ce..e96427fa6 100644 --- a/crates/registry-relay-v2/src/api.rs +++ b/crates/registry-relay-v2/src/api.rs @@ -399,7 +399,16 @@ pub async fn record_list( return unknown_data_route(&service, principal.as_ref(), &trace, OperationClass::List) .await; }; - let access = match access_operation(&service, resource, operation, principal, &trace).await { + let access = match access_operation( + &service, + resource, + operation, + uri.query(), + principal, + &trace, + ) + .await + { Ok(value) => value, Err(response) => return response, }; @@ -415,19 +424,6 @@ pub async fn record_list( ) .await; } - let access = match access_operation( - &service, - resource, - operation, - uri.query(), - principal, - &trace, - ) - .await - { - Ok(value) => value, - Err(response) => return response, - }; if rejects_caller_purpose(&headers) { return refuse_known( &service, @@ -629,26 +625,13 @@ pub async fn record_read( &service, resource, operation, - None, + Some(&access), AuditOutcome::InvalidRequest, ProblemCode::UriTooLong, &trace, ) .await; } - let access = match access_operation( - &service, - resource, - operation, - uri.query(), - &headers, - &trace, - ) - .await - { - Ok(value) => value, - Err(response) => return response, - }; if !valid_record_identifier(&record_identifier) { return refuse_known( &service, @@ -717,26 +700,13 @@ pub async fn record_lookup( &service, resource, operation, - None, + Some(&access), AuditOutcome::InvalidRequest, ProblemCode::UriTooLong, &trace, ) .await; } - let access = match access_operation( - &service, - resource, - operation, - request.uri().query(), - request.headers(), - &trace, - ) - .await - { - Ok(value) => value, - Err(response) => return response, - }; if rejects_caller_purpose(request.headers()) { return refuse_known( &service, @@ -1047,18 +1017,23 @@ async fn access_operation( ) -> Result> { let selected = match select_representation(operation, query) { Ok(value) => value, + Err(ProblemCode::ResourceNotFound) => { + return Err(refuse_unknown( + service, + principal_kind(principal.as_ref()), + AuditOutcome::NotFound, + ProblemCode::ResourceNotFound, + trace, + ) + .await); + } Err(code) => { - let outcome = if code == ProblemCode::RepresentationNotFound { - AuditOutcome::NotFound - } else { - AuditOutcome::InvalidRequest - }; return Err(refuse_before_representation( service, resource, operation, principal_kind(principal.as_ref()), - outcome, + AuditOutcome::InvalidRequest, code, trace, ) @@ -1066,6 +1041,7 @@ async fn access_operation( } }; let representation = selected.representation; + let explicit = selected.explicit; let authorization = match &service.authenticator { Some(authenticator) => authenticator.authorize(&representation.access, principal.as_ref()), None => match representation.access { @@ -1083,6 +1059,16 @@ async fn access_operation( representation: representation.clone(), }), Err(error) => { + if error == AuthorizationError::AuthenticationRequired && explicit { + return Err(refuse_unknown( + service, + PrincipalKind::Anonymous, + AuditOutcome::NotFound, + ProblemCode::ResourceNotFound, + trace, + ) + .await); + } if error == AuthorizationError::ScopeDenied { return Err(refuse_unknown( service, @@ -1463,25 +1449,85 @@ struct PreparedList { struct SelectedRepresentation<'a> { representation: &'a CompiledRepresentation, + explicit: bool, } fn select_representation<'a>( operation: &'a CompiledOperation, query: Option<&str>, ) -> Result, ProblemCode> { - let parameters = parse_query(query)?; - let requested = one_parameter(¶meters, "representation") - .map_err(|_| ProblemCode::RepresentationInvalid)?; - let identifier = requested.unwrap_or(&operation.default_representation); + let requested = representation_parameter(query)?; + let identifier = requested + .as_deref() + .unwrap_or(&operation.default_representation); if !valid_representation_identifier(identifier) { return Err(ProblemCode::RepresentationInvalid); } + let explicit = requested.is_some(); operation .representations .iter() .find(|representation| representation.id == identifier) - .map(|representation| SelectedRepresentation { representation }) - .ok_or(ProblemCode::RepresentationNotFound) + .map(|representation| SelectedRepresentation { + representation, + explicit, + }) + .ok_or(ProblemCode::ResourceNotFound) +} + +/// Extract only the representation selector before URI-shape refusal. +/// +/// This scans the already-buffered query in place and decodes only bounded +/// candidate names and the one bounded representation value. It therefore +/// preserves exact-profile authorization for an oversized URI without +/// allocating or decoding unrelated attacker-controlled query values. +fn representation_parameter(query: Option<&str>) -> Result, ProblemCode> { + const MAXIMUM_ENCODED_NAME_BYTES: usize = "representation".len() * 3; + const MAXIMUM_ENCODED_VALUE_BYTES: usize = 128 * 3; + + let Some(query) = query else { + return Ok(None); + }; + let mut requested = None; + for parameter in query.split('&') { + let (raw_name, raw_value) = parameter.split_once('=').unwrap_or((parameter, "")); + if raw_name.len() > MAXIMUM_ENCODED_NAME_BYTES { + continue; + } + if !valid_percent_encoding(raw_name.as_bytes()) { + // A malformed unrelated parameter remains an ordinary query-shape + // error after authorization. It cannot decode to the selector. + continue; + } + let name = decode_bounded_query_component(raw_name, MAXIMUM_ENCODED_NAME_BYTES)?; + if name != "representation" { + continue; + } + if requested.is_some() + || raw_value.len() > MAXIMUM_ENCODED_VALUE_BYTES + || raw_value.contains('=') + { + return Err(ProblemCode::RepresentationInvalid); + } + requested = Some(decode_bounded_query_component( + raw_value, + MAXIMUM_ENCODED_VALUE_BYTES, + )?); + } + Ok(requested) +} + +fn decode_bounded_query_component( + raw: &str, + maximum_encoded_bytes: usize, +) -> Result { + if raw.len() > maximum_encoded_bytes || !valid_percent_encoding(raw.as_bytes()) { + return Err(ProblemCode::RepresentationInvalid); + } + url::form_urlencoded::parse(raw.as_bytes()) + .next() + .map(|(value, _)| value.into_owned()) + .ok_or(ProblemCode::RepresentationInvalid) } fn valid_representation_identifier(value: &str) -> bool { @@ -2143,6 +2189,10 @@ fn add_record_id(service: &RelayService, resource: &CompiledResource, record: &m &format!("/v2/resources/{}/records/{identifier}", resource.id), )), ); + object.insert( + "@type".into(), + Value::String(resource.semantic_class.clone()), + ); } } @@ -2908,4 +2958,32 @@ mod tests { ); assert!(bounded_json_bytes(&document, expected.len().saturating_sub(1)).is_err()); } + + #[test] + fn representation_selection_scans_only_bounded_components() { + let padding = "x".repeat(20_000); + let query = format!("padding={padding}&representation=caseworker"); + assert_eq!( + representation_parameter(Some(&query)).expect("selector extracts"), + Some("caseworker".into()) + ); + assert_eq!( + representation_parameter(Some("%72epresentation=limited")) + .expect("encoded selector extracts"), + Some("limited".into()) + ); + assert_eq!( + representation_parameter(Some("%=ignored&representation=limited")) + .expect("malformed unrelated name is deferred"), + Some("limited".into()) + ); + assert_eq!( + representation_parameter(Some("representation=limited&representation=caseworker")), + Err(ProblemCode::RepresentationInvalid) + ); + assert_eq!( + representation_parameter(Some("representation=limited=caseworker")), + Err(ProblemCode::RepresentationInvalid) + ); + } } diff --git a/crates/registry-relay-v2/src/compiler.rs b/crates/registry-relay-v2/src/compiler.rs index 230aaf288..18a4786a9 100644 --- a/crates/registry-relay-v2/src/compiler.rs +++ b/crates/registry-relay-v2/src/compiler.rs @@ -1456,6 +1456,14 @@ impl<'a> Compiler<'a> { .find(|property| property.name == filter.property) { Some(property) => { + if property.transform.is_some() { + self.error( + "list.filter_property_transformed", + &filter_location, + "transformed properties cannot be used as list filters", + ); + continue; + } if property.data_type != filter.data_type { self.error( "list.filter_type_mismatch", @@ -1486,7 +1494,7 @@ impl<'a> Compiler<'a> { } let mut order = HashSet::new(); let mut order_columns = HashSet::new(); - for property_name in &list.order_by { + for (index, property_name) in list.order_by.iter().enumerate() { if !order.insert(property_name.as_str()) { self.error( "list.order_duplicate", @@ -1499,6 +1507,14 @@ impl<'a> Compiler<'a> { .find(|property| property.name == *property_name) { Some(property) => { + if property.transform.is_some() { + self.error( + "list.order_property_transformed", + &format!("{location}.orderBy[{index}]"), + "transformed properties cannot be used as fixed order keys", + ); + continue; + } if !property.source_required { self.error( "list.order_property_optional", @@ -1597,6 +1613,70 @@ impl<'a> Compiler<'a> { } } + fn compile_transform( + &mut self, + definition: Option<&TransformDefinition>, + output_type: DataType, + location: &str, + ) -> Option { + match definition? { + TransformDefinition::PartialString { reveal, characters } => { + if output_type != DataType::String { + self.error( + "transform.output_type_invalid", + &format!("{location}.type"), + "partial-string transforms must publish a string property", + ); + } + if !(1..=MAXIMUM_PARTIAL_STRING_CHARACTERS).contains(characters) { + self.error( + "transform.partial_string_characters_invalid", + &format!("{location}.transform.characters"), + "partial-string reveal length must be within the fixed product bound", + ); + } + let reveal_label = match reveal { + crate::contract::PartialStringReveal::Prefix => "prefix", + crate::contract::PartialStringReveal::Suffix => "suffix", + }; + Some(CompiledTransform::PartialString { + identifier: format!("partial-string:{reveal_label}:{characters}"), + reveal: *reveal, + characters: *characters, + }) + } + TransformDefinition::DatePrecision { + source_type, + precision, + } => { + let expected_output = match precision { + DatePrecision::Year => DataType::Year, + DatePrecision::YearMonth => DataType::YearMonth, + }; + if output_type != expected_output { + self.error( + "transform.output_type_invalid", + &format!("{location}.type"), + "date-precision output datatype must match the selected precision", + ); + } + let source_label = match source_type { + DateInputType::Date => "date", + DateInputType::DateTime => "date-time", + }; + let precision_label = match precision { + DatePrecision::Year => "year", + DatePrecision::YearMonth => "year-month", + }; + Some(CompiledTransform::DatePrecision { + identifier: format!("date-precision:{source_label}:{precision_label}"), + source_type: *source_type, + precision: *precision, + }) + } + } + } + fn compile_access( &mut self, access: &AccessRule, @@ -2837,6 +2917,21 @@ fn compatible_declared_type(data_type: DataType, declared_type: &str) -> bool { } } +fn transform_source_type(transform: Option<&CompiledTransform>, output_type: DataType) -> DataType { + match transform { + Some(CompiledTransform::PartialString { .. }) => DataType::String, + Some(CompiledTransform::DatePrecision { + source_type: DateInputType::Date, + .. + }) => DataType::Date, + Some(CompiledTransform::DatePrecision { + source_type: DateInputType::DateTime, + .. + }) => DataType::DateTime, + None => output_type, + } +} + fn cursor_order_type_supported(data_type: DataType) -> bool { matches!( data_type, @@ -3086,6 +3181,28 @@ pub(crate) mod tests { })); } + #[test] + fn every_referenced_selector_codelist_must_be_in_the_governed_closure() { + let yaml = valid_contract() + .replace( + "read:\n defaultRepresentation: public\n representations:\n public: {access: public, disclosureProfile: public}", + "lookups:\n - id: by-name\n requestBody:\n maximumBytes: 1024\n selectors:\n name: {sourceColumn: name, type: controlled-code, codelist: codelists/selector-names.yaml}\n defaultRepresentation: public\n representations:\n public: {access: public, disclosureProfile: public}", + ) + .replace("operationRefs: [read]", "operationRefs: [lookup:by-name]"); + let contract = RegistryContract::parse_yaml(&yaml).expect("strict lookup contract"); + let report = compile_contract_with_governed_files( + &contract, + &[observed_schema()], + CompileProfile::Production, + &governed_files_for(&contract), + ) + .expect_err("a selector codelist cannot be absent"); + assert!(report.diagnostics.iter().any(|diagnostic| { + diagnostic.code == "contract.governed_file_missing" + && diagnostic.location == "codelists/selector-names.yaml" + })); + } + #[test] fn list_order_ends_in_one_non_null_string_record_identifier() { let yaml = valid_contract() @@ -3144,6 +3261,65 @@ pub(crate) mod tests { .any(|diagnostic| { diagnostic.code == "list.order_column_type_unsupported" })); } + #[test] + fn transformed_properties_cannot_be_list_filters_or_order_keys() { + let transformed = valid_contract() + .replace( + " sourceColumnClassifications: {}", + " sourceColumnClassifications:\n name: {privacy: non-personal, institutional: public, handling: public, status: reviewed}", + ) + .replace( + " semanticTerm: local:name\n disclosureProfiles", + " semanticTerm: local:name\n transform: {kind: partial-string, reveal: suffix, characters: 4}\n disclosureProfiles", + ); + + let filtered = transformed + .replace( + "read:\n defaultRepresentation: public\n representations:\n public: {access: public, disclosureProfile: public}", + "list:\n defaultRepresentation: public\n representations:\n public: {access: public, disclosureProfile: public}\n filters:\n - {name: byName, property: name, type: string}\n allowUnfiltered: false\n orderBy: []\n pagination: {defaultPageSize: 1, maximumPageSize: 10}", + ) + .replace("operationRefs: [read]", "operationRefs: [list]"); + let contract = RegistryContract::parse_yaml(&filtered).expect("strict filter contract"); + let report = compile_contract(&contract, &[observed_schema()], CompileProfile::Production) + .expect_err("a transformed filter cannot compare its raw source input"); + let diagnostic = report + .diagnostics + .iter() + .find(|diagnostic| diagnostic.code == "list.filter_property_transformed") + .expect("stable transformed-filter diagnostic"); + assert_eq!( + diagnostic.location, + "resources[0].operations.list.filters[0]" + ); + assert_eq!( + diagnostic.message, + "transformed properties cannot be used as list filters" + ); + + let ordered = transformed + .replace( + "read:\n defaultRepresentation: public\n representations:\n public: {access: public, disclosureProfile: public}", + "list:\n defaultRepresentation: public\n representations:\n public: {access: public, disclosureProfile: public}\n filters: []\n allowUnfiltered: true\n orderBy: [name]\n pagination: {defaultPageSize: 1, maximumPageSize: 10}", + ) + .replace("operationRefs: [read]", "operationRefs: [list]"); + let contract = RegistryContract::parse_yaml(&ordered).expect("strict order contract"); + let report = compile_contract(&contract, &[observed_schema()], CompileProfile::Production) + .expect_err("a transformed order key cannot compare its raw source input"); + let diagnostic = report + .diagnostics + .iter() + .find(|diagnostic| diagnostic.code == "list.order_property_transformed") + .expect("stable transformed-order diagnostic"); + assert_eq!( + diagnostic.location, + "resources[0].operations.list.orderBy[0]" + ); + assert_eq!( + diagnostic.message, + "transformed properties cannot be used as fixed order keys" + ); + } + #[test] fn sqlite_view_nullable_metadata_does_not_override_required_order_contract() { let yaml = valid_contract() @@ -3828,7 +4004,7 @@ pub(crate) mod tests { governed_files_for(&contract) } - fn governed_files_for(contract: &RegistryContract) -> GovernedFileSet { + pub(crate) fn governed_files_for(contract: &RegistryContract) -> GovernedFileSet { let compiled = compile_contract(contract, &[observed_schema()], CompileProfile::Production) .expect("inventory compiles"); let inventory_digest = diff --git a/crates/registry-relay-v2/src/fixture_contract.rs b/crates/registry-relay-v2/src/fixture_contract.rs new file mode 100644 index 000000000..4c5632ae3 --- /dev/null +++ b/crates/registry-relay-v2/src/fixture_contract.rs @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Strict shared wire contract for offline HTTP acceptance journeys. + +use std::collections::{BTreeMap, BTreeSet}; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use thiserror::Error; + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct FixtureJourney { + pub schema_version: String, + pub registry: String, + #[serde(default)] + pub authorizations: BTreeMap, + pub steps: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct FixtureAuthorization { + pub principal: String, + pub scopes: BTreeSet, + #[serde(default)] + pub claims: BTreeMap, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct FixtureStep { + pub id: String, + #[serde(default)] + pub authorization_fixture: Option, + pub request: FixtureRequest, + pub expect: FixtureExpectation, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct FixtureRequest { + pub method: FixtureMethod, + pub path: String, + #[serde(default)] + pub headers: BTreeMap, + #[serde(default)] + pub query: BTreeMap, + #[serde(default)] + pub body: BTreeMap, +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "UPPERCASE")] +pub enum FixtureMethod { + Get, + Post, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct FixtureExpectation { + pub status: u16, + #[serde(default)] + pub code: Option, + #[serde(default)] + pub capability_patterns: Vec, + #[serde(default)] + pub absent_capability_patterns: Vec, + #[serde(default)] + pub item_count: Option, + #[serde(default)] + pub next_cursor: Option, + #[serde(default)] + pub registry_core_required: Option, + #[serde(default)] + pub domain_data_keys: Vec, + #[serde(default)] + pub domain_data_values: BTreeMap, + #[serde(default)] + pub record_identifier: Option, + #[serde(default)] + pub cache: Option, + #[serde(default)] + pub route_absent: Option, + #[serde(default)] + pub equivalence_class: Option, + #[serde(default)] + pub absent_everywhere: Vec, + #[serde(default)] + pub records_equivalent_to: Option, + #[serde(default)] + pub body_empty: Option, + #[serde(default)] + pub etag_same_as: Option, +} + +#[derive(Debug, Error)] +pub enum FixtureError { + #[error("fixture YAML is not valid")] + InvalidYaml, +} + +pub fn parse_journey(yaml: &str) -> Result { + serde_norway::from_str(yaml).map_err(|_| FixtureError::InvalidYaml) +} diff --git a/crates/registry-relay-v2/src/fixtures.rs b/crates/registry-relay-v2/src/fixtures.rs index f665a6b88..858600b89 100644 --- a/crates/registry-relay-v2/src/fixtures.rs +++ b/crates/registry-relay-v2/src/fixtures.rs @@ -11,99 +11,19 @@ use base64::engine::general_purpose::URL_SAFE_NO_PAD; use base64::Engine as _; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; -use thiserror::Error; use tower::ServiceExt as _; use crate::auth::{FixturePrincipal, RelayAuthenticator}; +pub use crate::fixture_contract::{ + parse_journey, FixtureAuthorization, FixtureError, FixtureExpectation, FixtureJourney, + FixtureMethod, FixtureRequest, FixtureStep, +}; use crate::model::{CompiledAccess, CompiledRegistry, OperationKind}; const JOURNEY_VERSION: &str = "relay.registrystack.org/http-journey/v1alpha1"; const MAXIMUM_RESPONSE_BYTES: usize = 8 * 1024 * 1024; - -#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -pub struct FixtureJourney { - pub schema_version: String, - pub registry: String, - #[serde(default)] - pub authorizations: BTreeMap, - pub steps: Vec, -} - -#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -pub struct FixtureAuthorization { - pub principal: String, - pub scopes: BTreeSet, - #[serde(default)] - pub claims: BTreeMap, -} - -#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -pub struct FixtureStep { - pub id: String, - #[serde(default)] - pub authorization_fixture: Option, - pub request: FixtureRequest, - pub expect: FixtureExpectation, -} - -#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -pub struct FixtureRequest { - pub method: FixtureMethod, - pub path: String, - #[serde(default)] - pub headers: BTreeMap, - #[serde(default)] - pub query: BTreeMap, - #[serde(default)] - pub body: BTreeMap, -} - -#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] -#[serde(rename_all = "UPPERCASE")] -pub enum FixtureMethod { - Get, - Post, -} - -#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -pub struct FixtureExpectation { - pub status: u16, - #[serde(default)] - pub code: Option, - #[serde(default)] - pub capability_patterns: Vec, - #[serde(default)] - pub absent_capability_patterns: Vec, - #[serde(default)] - pub item_count: Option, - #[serde(default)] - pub next_cursor: Option, - #[serde(default)] - pub registry_core_required: Option, - #[serde(default)] - pub domain_data_keys: Vec, - #[serde(default)] - pub record_identifier: Option, - #[serde(default)] - pub cache: Option, - #[serde(default)] - pub route_absent: Option, - #[serde(default)] - pub equivalence_class: Option, - #[serde(default)] - pub absent_everywhere: Vec, - #[serde(default)] - pub records_equivalent_to: Option, - #[serde(default)] - pub body_empty: Option, - #[serde(default)] - pub etag_same_as: Option, -} +const MAXIMUM_DOMAIN_VALUE_EXPECTATIONS: usize = 64; +const MAXIMUM_DOMAIN_PROPERTY_NAME_BYTES: usize = 128; #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] @@ -139,16 +59,6 @@ pub struct FixtureDiagnostic { pub message: String, } -#[derive(Debug, Error)] -pub enum FixtureError { - #[error("fixture YAML is not valid")] - InvalidYaml, -} - -pub fn parse_journey(yaml: &str) -> Result { - serde_norway::from_str(yaml).map_err(|_| FixtureError::InvalidYaml) -} - /// Compile a journey against the exact runtime operation inventory without /// opening the source. This is the structural preflight used before execution. pub fn compile_fixture_plan( @@ -218,6 +128,11 @@ pub fn compile_fixture_plan( "the expected status is outside the Relay problem contract", ); } + validate_domain_data_expectations( + &step.expect, + &format!("steps[{index}].expect.domainDataValues"), + &mut diagnostics, + ); let operation = resolve_operation(registry, &step.request); if step.expect.status == 200 && is_data_path(&step.request.path) && operation.is_none() { diagnostic( @@ -580,6 +495,28 @@ fn assert_expectations( ); } } + if !step.expect.domain_data_values.is_empty() + && (records.is_empty() + || records.iter().any(|record| { + step.expect + .domain_data_values + .iter() + .any(|(property, expected)| { + record + .get("domainData") + .and_then(Value::as_object) + .and_then(|domain| domain.get(property)) + != Some(expected) + }) + })) + { + mismatch( + diagnostics, + "fixture.domain_value_mismatch", + &location, + "governed domain value", + ); + } if let Some(expected) = step.expect.record_identifier.as_deref() { let actual = response .document @@ -816,6 +753,7 @@ fn normalized_records(document: &Value) -> Value { if let Some(object) = record.as_object_mut() { object.remove("@context"); object.remove("@id"); + object.remove("@type"); } } Value::Array(records) @@ -858,6 +796,50 @@ fn has_registry_core(record: &Value) -> bool { .all(|key| record.get(key).is_some()) } +fn validate_domain_data_expectations( + expectation: &FixtureExpectation, + location: &str, + diagnostics: &mut Vec, +) { + if expectation.domain_data_values.len() > MAXIMUM_DOMAIN_VALUE_EXPECTATIONS { + diagnostic( + diagnostics, + "fixture.domain_values_invalid", + location, + "exact domain-value expectations exceed the fixture bound", + ); + } + for (property, expected) in &expectation.domain_data_values { + if property.len() > MAXIMUM_DOMAIN_PROPERTY_NAME_BYTES { + diagnostic( + diagnostics, + "fixture.domain_values_invalid", + location, + "an exact domain-value expectation name exceeds the fixture bound", + ); + } + if !matches!( + expected, + Value::Bool(_) | Value::Number(_) | Value::String(_) + ) { + diagnostic( + diagnostics, + "fixture.domain_values_invalid", + location, + "exact domain-value expectations must be non-null JSON scalars", + ); + } + if !expectation.domain_data_keys.contains(property) { + diagnostic( + diagnostics, + "fixture.domain_values_invalid", + location, + "every exact domain-value expectation must be closed by domainDataKeys", + ); + } + } +} + fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool { !needle.is_empty() && haystack @@ -904,6 +886,117 @@ steps: assert!(parse_journey(yaml).is_err()); } + #[test] + fn fixture_yaml_accepts_closed_scalar_domain_value_expectations() { + let yaml = r#" +schemaVersion: relay.registrystack.org/http-journey/v1alpha1 +registry: urn:example:registry +authorizations: {} +steps: + - id: one + request: {method: GET, path: /health} + expect: + status: 200 + domainDataKeys: [maskedReference, registrationYear] + domainDataValues: {maskedReference: "***0001", registrationYear: "2026"} +"#; + let journey = parse_journey(yaml).expect("fixture parses"); + assert_eq!( + journey.steps[0].expect.domain_data_values, + BTreeMap::from([ + ("maskedReference".into(), Value::String("***0001".into())), + ("registrationYear".into(), Value::String("2026".into())), + ]) + ); + } + + #[test] + fn domain_value_expectations_are_bounded_scalar_and_closed() { + let mut expectation = FixtureExpectation { + status: 200, + domain_data_keys: vec!["allowed".into()], + domain_data_values: BTreeMap::from([ + ("notClosed".into(), Value::String("safe".into())), + ("overlong".repeat(17), Value::String("safe".into())), + ("structured".into(), json!(["not", "scalar"])), + ]), + ..FixtureExpectation::default() + }; + for index in 0..=MAXIMUM_DOMAIN_VALUE_EXPECTATIONS { + expectation + .domain_data_values + .insert(format!("extra{index}"), Value::Bool(true)); + } + let mut diagnostics = Vec::new(); + validate_domain_data_expectations(&expectation, "expect", &mut diagnostics); + assert!(diagnostics.len() >= 4); + assert!(diagnostics + .iter() + .all(|diagnostic| diagnostic.code == "fixture.domain_values_invalid")); + let rendered = serde_json::to_string(&diagnostics).expect("diagnostics serialize"); + assert!(!rendered.contains("safe")); + assert!(!rendered.contains("not scalar")); + } + + #[test] + fn exact_domain_value_assertion_is_value_free_on_mismatch() { + let yaml = r#" +schemaVersion: relay.registrystack.org/http-journey/v1alpha1 +registry: urn:example:registry +authorizations: {} +steps: + - id: one + request: {method: GET, path: /health} + expect: + status: 200 + domainDataKeys: [maskedReference] + domainDataValues: {maskedReference: "***0001"} +"#; + let journey = parse_journey(yaml).expect("fixture parses"); + let step = &journey.steps[0]; + let headers = http::HeaderMap::new(); + let mut equivalence_classes = BTreeMap::new(); + let observations = BTreeMap::new(); + let matching = json!({"data": {"domainData": {"maskedReference": "***0001"}}}); + let mut diagnostics = Vec::new(); + assert!(assert_expectations( + step, + &ObservedResponse { + status: StatusCode::OK, + headers: &headers, + body: b"", + document: Some(&matching), + code: None, + }, + &mut equivalence_classes, + &observations, + 0, + &mut diagnostics, + )); + + let mismatching = json!({"data": {"domainData": {"maskedReference": "SOURCE-SECRET"}}}); + assert!(!assert_expectations( + step, + &ObservedResponse { + status: StatusCode::OK, + headers: &headers, + body: b"", + document: Some(&mismatching), + code: None, + }, + &mut equivalence_classes, + &observations, + 0, + &mut diagnostics, + )); + let rendered = serde_json::to_string(&diagnostics).expect("diagnostics serialize"); + assert!(!rendered.contains("SOURCE-SECRET")); + assert!(!rendered.contains("***0001")); + assert!(diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "fixture.domain_value_mismatch")); + } + #[test] fn fixture_tokens_have_a_bounded_jwt_shape_without_exposing_claims() { let token = fixture_token("fixture-a"); diff --git a/crates/registry-relay-v2/src/lib.rs b/crates/registry-relay-v2/src/lib.rs index 9deb8205d..a5bf2f5f3 100644 --- a/crates/registry-relay-v2/src/lib.rs +++ b/crates/registry-relay-v2/src/lib.rs @@ -9,6 +9,7 @@ pub mod compiler; pub mod contract; pub mod cursor; pub mod diff; +pub mod fixture_contract; #[cfg(feature = "tooling")] pub mod fixtures; pub mod identification; diff --git a/crates/registry-relay-v2/src/package.rs b/crates/registry-relay-v2/src/package.rs index 04be36d65..9999f22cb 100644 --- a/crates/registry-relay-v2/src/package.rs +++ b/crates/registry-relay-v2/src/package.rs @@ -10,7 +10,9 @@ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use thiserror::Error; -use crate::artifacts::{ArtifactSet, GeneratedArtifact, OperationArtifactBindings}; +use crate::artifacts::{ + generate_artifacts, ArtifactSet, GeneratedArtifact, OperationArtifactBindings, +}; use crate::compiler::{ compile_contract_with_governed_files, referenced_governed_files, GovernedFileSet, }; @@ -19,9 +21,6 @@ use crate::model::{ CompileProfile, CompiledClassificationReview, CompiledRegistry, ObservedSourceSchema, }; -#[cfg(test)] -use crate::artifacts::generate_artifacts; - const PACKAGE_VERSION: &str = "relay.registrystack.org/package/v1alpha2"; const COMPILED_REGISTRY_PATH: &str = "compiled/registry.json"; const MAX_AUTHORED_FILES: usize = 256; @@ -265,12 +264,8 @@ fn validate_build_inputs( }) .collect::, _>>()?; verify_compiled_derivation(contract, compiled, governed, &observed)?; - let operation_identifiers = compiled - .resources - .iter() - .flat_map(|resource| resource.operations.iter()) - .map(|operation| operation.identifier.as_str()) - .collect::>(); + verify_artifact_derivation(compiled, artifacts)?; + let expected_operation_representations = operation_representation_pairs(compiled); let mut artifact_ids = BTreeSet::new(); let mut artifact_paths = BTreeSet::new(); for artifact in &artifacts.artifacts { @@ -281,14 +276,17 @@ fn validate_build_inputs( || artifact .operation_identifier .as_deref() - .is_some_and(|identifier| !operation_identifiers.contains(identifier)) + .zip(artifact.representation_identifier.as_deref()) + .is_some_and(|pair| !expected_operation_representations.contains(&pair)) + || artifact.operation_identifier.is_some() + != artifact.representation_identifier.is_some() { return Err(PackageError::Verification); } } if !valid_operation_artifact_bindings( &artifacts.operation_bindings, - &operation_identifiers, + &expected_operation_representations, &artifact_paths, ) { return Err(PackageError::Verification); @@ -315,6 +313,20 @@ fn verify_compiled_derivation( Ok(()) } +fn verify_artifact_derivation( + compiled: &CompiledRegistry, + artifacts: &ArtifactSet, +) -> Result<(), PackageError> { + // `packageRevision` is an integrity digest, not an authenticity proof. A + // caller can recalculate it, so acceptance must reproduce every artifact + // byte and its release metadata from the already rederived Registry. + let reproduced = generate_artifacts(compiled).map_err(|_| PackageError::Verification)?; + if reproduced != *artifacts { + return Err(PackageError::Verification); + } + Ok(()) +} + /// Load and verify a sealed package before any listener, issuer, audit sink, /// or SQLite source is activated. pub fn load_package(package_path: &Path) -> Result { @@ -517,12 +529,7 @@ pub fn load_package(package_path: &Path) -> Result>(); + let expected_operation_representations = operation_representation_pairs(®istry); let mut artifact_ids = BTreeSet::new(); let mut artifact_paths = BTreeSet::new(); let mut generated_artifacts = Vec::with_capacity(manifest.artifacts.len()); @@ -537,7 +544,10 @@ pub fn load_package(package_path: &Path) -> Result Result Result { fn valid_operation_artifact_bindings( bindings: &[OperationArtifactBindings], - operation_identifiers: &BTreeSet<&str>, + expected_operation_representations: &BTreeSet<(&str, &str)>, artifact_paths: &BTreeSet<&str>, ) -> bool { - let mut bound_operations = BTreeSet::new(); + let mut bound_operation_representations = BTreeSet::new(); for binding in bindings { - if !operation_identifiers.contains(binding.operation_identifier.as_str()) - || !bound_operations.insert(binding.operation_identifier.as_str()) + let pair = ( + binding.operation_identifier.as_str(), + binding.representation_identifier.as_str(), + ); + if !expected_operation_representations.contains(&pair) + || !bound_operation_representations.insert(pair) || [ binding.vocabulary_path.as_str(), binding.context_path.as_str(), @@ -630,7 +645,21 @@ fn valid_operation_artifact_bindings( return false; } } - bound_operations == *operation_identifiers + bound_operation_representations == *expected_operation_representations +} + +fn operation_representation_pairs(registry: &CompiledRegistry) -> BTreeSet<(&str, &str)> { + registry + .resources + .iter() + .flat_map(|resource| resource.operations.iter()) + .flat_map(|operation| { + operation + .representations + .iter() + .map(|representation| (operation.identifier.as_str(), representation.id.as_str())) + }) + .collect() } fn capture_governed_closure( @@ -925,6 +954,52 @@ fn media_type(path: &str) -> &'static str { mod tests { use super::*; + fn reseal_manifest(package_path: &Path, manifest: &mut PackageManifest) { + let unsigned = UnsignedManifest { + package_version: PACKAGE_VERSION, + contract_revision: &manifest.contract_revision, + source_schema_fingerprints: &manifest.source_schema_fingerprints, + source_schemas: &manifest.source_schemas, + artifacts: &manifest.artifacts, + operation_artifact_bindings: &manifest.operation_artifact_bindings, + files: &manifest.files, + }; + let unsigned_bytes = canonicalize_json( + &serde_json::to_value(unsigned).expect("unsigned manifest serializes"), + ) + .expect("unsigned manifest canonicalizes"); + manifest.package_revision = digest(&unsigned_bytes); + let manifest_bytes = + canonicalize_json(&serde_json::to_value(manifest).expect("sealed manifest serializes")) + .expect("sealed manifest canonicalizes"); + fs::write(package_path.join("relay-package.json"), manifest_bytes) + .expect("forged manifest writes"); + } + + fn assert_resealed_package_rejected( + root: &Path, + project: &Path, + name: &str, + contract: &RegistryContract, + registry: &CompiledRegistry, + artifacts: &ArtifactSet, + mutate: impl FnOnce(&Path, &mut PackageManifest), + ) { + let package_path = root.join(name); + let mut manifest = build_package(project, &package_path, contract, registry, artifacts) + .expect("forgery fixture"); + mutate(&package_path, &mut manifest); + reseal_manifest(&package_path, &mut manifest); + assert!(matches!( + load_package( + &package_path + .canonicalize() + .expect("forged package resolves") + ), + Err(PackageError::Verification) + )); + } + #[test] fn package_references_cannot_escape() { assert!(validate_relative("governance/review.yaml").is_ok()); @@ -932,6 +1007,95 @@ mod tests { assert!(validate_relative("/absolute.yaml").is_err()); } + #[test] + fn multi_representation_package_bindings_are_exactly_closed() { + let yaml = crate::compiler::tests::valid_contract() + .replace( + "read:\n defaultRepresentation: public\n representations:\n public: {access: public, disclosureProfile: public}", + "read:\n defaultRepresentation: public\n representations:\n public: {access: public, disclosureProfile: public}\n alternate: {access: public, disclosureProfile: public}\n list:\n defaultRepresentation: listing\n representations:\n listing: {access: public, disclosureProfile: public}\n filters: []\n allowUnfiltered: true\n orderBy: [name]\n pagination: {defaultPageSize: 1, maximumPageSize: 10}", + ) + .replace("operationRefs: [read]", "operationRefs: [read, list]"); + let contract = RegistryContract::parse_yaml(&yaml).expect("strict multi-profile contract"); + let governed = crate::compiler::tests::governed_files_for(&contract); + let registry = compile_contract_with_governed_files( + &contract, + &[crate::compiler::tests::observed_schema()], + CompileProfile::Production, + &governed, + ) + .expect("multi-profile Registry compiles"); + let artifacts = generate_artifacts(®istry).expect("multi-profile artifacts generate"); + let expected = operation_representation_pairs(®istry); + let artifact_paths = artifacts + .artifacts + .iter() + .map(|artifact| artifact.path.as_str()) + .collect::>(); + + assert_eq!(expected.len(), 3); + assert!(valid_operation_artifact_bindings( + &artifacts.operation_bindings, + &expected, + &artifact_paths, + )); + + let mut missing = artifacts.operation_bindings.clone(); + missing.pop(); + assert!(!valid_operation_artifact_bindings( + &missing, + &expected, + &artifact_paths, + )); + + let mut duplicate = artifacts.operation_bindings.clone(); + duplicate.push(duplicate[0].clone()); + assert!(!valid_operation_artifact_bindings( + &duplicate, + &expected, + &artifact_paths, + )); + + let mut cross_operation = artifacts.operation_bindings.clone(); + let listing_representation = cross_operation + .iter() + .find(|binding| binding.operation_identifier.ends_with(".list")) + .expect("list binding") + .representation_identifier + .clone(); + let read_binding = cross_operation + .iter_mut() + .find(|binding| binding.operation_identifier.ends_with(".read")) + .expect("read binding"); + read_binding.representation_identifier = listing_representation; + assert!(!valid_operation_artifact_bindings( + &cross_operation, + &expected, + &artifact_paths, + )); + + let temporary = tempfile::tempdir().expect("temporary project"); + let project = temporary.path().join("project"); + fs::create_dir(&project).expect("project directory"); + fs::write(project.join("registry.yaml"), &yaml).expect("registry contract"); + for (relative, content) in &governed { + let path = project.join(relative); + fs::create_dir_all(path.parent().expect("governed parent")) + .expect("governed directory"); + fs::write(path, content).expect("governed file"); + } + let package_path = temporary.path().join("package"); + let manifest = build_package(&project, &package_path, &contract, ®istry, &artifacts) + .expect("multi-profile package builds"); + let verified = load_package( + &package_path + .canonicalize() + .expect("multi-profile package resolves"), + ) + .expect("multi-profile package loads"); + assert_eq!(verified.manifest, manifest); + assert_eq!(verified.artifacts.operation_bindings.len(), 3); + } + #[cfg(unix)] #[test] fn governed_capture_rejects_intermediate_symlinks() { @@ -1065,6 +1229,58 @@ mod tests { ), Err(PackageError::Verification) )); + let mut tampered_artifact_bytes = artifacts.clone(); + let tampered_artifact = tampered_artifact_bytes + .artifacts + .first_mut() + .expect("generated artifact"); + tampered_artifact.content.extend_from_slice(b"tampered"); + tampered_artifact.sha256 = digest(&tampered_artifact.content); + assert!(matches!( + build_package( + &project, + &temporary.path().join("rejected-artifact-bytes"), + &contract, + ®istry, + &tampered_artifact_bytes, + ), + Err(PackageError::Verification) + )); + let mut tampered_artifact_visibility = artifacts.clone(); + let tampered_artifact = tampered_artifact_visibility + .artifacts + .first_mut() + .expect("generated artifact"); + tampered_artifact.visibility = match tampered_artifact.visibility { + Visibility::OperatorOnly => Visibility::Public, + Visibility::Public | Visibility::OperationBound => Visibility::OperatorOnly, + }; + assert!(matches!( + build_package( + &project, + &temporary.path().join("rejected-artifact-visibility"), + &contract, + ®istry, + &tampered_artifact_visibility, + ), + Err(PackageError::Verification) + )); + let mut tampered_artifact_binding = artifacts.clone(); + let binding = tampered_artifact_binding + .operation_bindings + .first_mut() + .expect("operation artifact binding"); + binding.context_path = binding.vocabulary_path.clone(); + assert!(matches!( + build_package( + &project, + &temporary.path().join("rejected-artifact-binding"), + &contract, + ®istry, + &tampered_artifact_binding, + ), + Err(PackageError::Verification) + )); let mut mismatched_registry = registry.clone(); mismatched_registry.registry_name = "Different Registry semantics".into(); assert!(matches!( @@ -1097,6 +1313,114 @@ mod tests { artifacts.operation_bindings ); + assert_resealed_package_rejected( + temporary.path(), + &project, + "forged-compiled-registry", + &contract, + ®istry, + &artifacts, + |package_path, manifest| { + let compiled_path = package_path.join(COMPILED_REGISTRY_PATH); + let mut forged: CompiledRegistry = serde_json::from_slice( + &fs::read(&compiled_path).expect("compiled Registry bytes"), + ) + .expect("compiled Registry parses"); + forged.registry_name = "Forged Registry semantics".into(); + let forged_bytes = canonicalize_json( + &serde_json::to_value(forged).expect("forged Registry serializes"), + ) + .expect("forged Registry canonicalizes"); + fs::write(&compiled_path, &forged_bytes).expect("forged Registry writes"); + let file = manifest + .files + .iter_mut() + .find(|file| file.path == COMPILED_REGISTRY_PATH) + .expect("compiled Registry package file"); + file.size = forged_bytes.len() as u64; + file.sha256 = digest(&forged_bytes); + }, + ); + assert_resealed_package_rejected( + temporary.path(), + &project, + "forged-artifact-content", + &contract, + ®istry, + &artifacts, + |package_path, manifest| { + let artifact = manifest.artifacts.first_mut().expect("generated artifact"); + let file_path = artifact.path.clone(); + let mut content = fs::read(package_path.join(&file_path)).expect("artifact bytes"); + content.extend_from_slice(b"tampered"); + fs::write(package_path.join(&file_path), &content).expect("forged artifact bytes"); + let content_digest = digest(&content); + artifact.sha256 = content_digest.clone(); + let file = manifest + .files + .iter_mut() + .find(|file| file.path == file_path) + .expect("generated package file"); + file.size = content.len() as u64; + file.sha256 = content_digest; + }, + ); + assert_resealed_package_rejected( + temporary.path(), + &project, + "forged-artifact-visibility", + &contract, + ®istry, + &artifacts, + |_package_path, manifest| { + let artifact = manifest.artifacts.first_mut().expect("generated artifact"); + let file_path = artifact.path.clone(); + let visibility = match artifact.visibility { + Visibility::OperatorOnly => Visibility::Public, + Visibility::Public | Visibility::OperationBound => Visibility::OperatorOnly, + }; + artifact.visibility = visibility; + manifest + .files + .iter_mut() + .find(|file| file.path == file_path) + .expect("generated package file") + .visibility = visibility; + }, + ); + assert_resealed_package_rejected( + temporary.path(), + &project, + "forged-swapped-binding", + &contract, + ®istry, + &artifacts, + |_package_path, manifest| { + let binding = manifest + .operation_artifact_bindings + .first_mut() + .expect("operation artifact binding"); + let vocabulary_path = binding.vocabulary_path.clone(); + binding.vocabulary_path = binding.context_path.clone(); + binding.context_path = vocabulary_path; + }, + ); + assert_resealed_package_rejected( + temporary.path(), + &project, + "forged-repeated-binding", + &contract, + ®istry, + &artifacts, + |_package_path, manifest| { + let binding = manifest + .operation_artifact_bindings + .first_mut() + .expect("operation artifact binding"); + binding.context_path = binding.vocabulary_path.clone(); + }, + ); + let compiled_path = output.join(COMPILED_REGISTRY_PATH); let compiled_bytes = fs::read(&compiled_path).expect("compiled Registry bytes"); fs::write(&compiled_path, b"{}").expect("tamper compiled Registry"); diff --git a/crates/registry-relay-v2/src/problem.rs b/crates/registry-relay-v2/src/problem.rs index 29129059e..3e3408654 100644 --- a/crates/registry-relay-v2/src/problem.rs +++ b/crates/registry-relay-v2/src/problem.rs @@ -22,7 +22,6 @@ pub enum ProblemCode { InvalidFilter, CursorInvalid, RepresentationInvalid, - RepresentationNotFound, MissingCredential, InvalidCredential, ConsultationDenied, @@ -50,7 +49,6 @@ impl ProblemCode { Self::InvalidFilter => "filter.invalid_value", Self::CursorInvalid => "query.cursor_invalid", Self::RepresentationInvalid => "request.representation_invalid", - Self::RepresentationNotFound => "representation.not_found", Self::MissingCredential => "auth.missing_credential", Self::InvalidCredential => "auth.invalid_credential", Self::ConsultationDenied => "consultation.denied", @@ -78,7 +76,6 @@ impl ProblemCode { Self::InvalidFilter => "Filter value is invalid", Self::CursorInvalid => "Cursor is invalid", Self::RepresentationInvalid => "Representation selection is invalid", - Self::RepresentationNotFound => "Requested representation was not found", Self::MissingCredential => "Bearer access token is required", Self::InvalidCredential => "Bearer access token is invalid", Self::ConsultationDenied => "Consultation is not permitted", @@ -108,9 +105,7 @@ impl ProblemCode { | Self::RepresentationInvalid => 400, Self::MissingCredential | Self::InvalidCredential => 401, Self::ConsultationDenied => 403, - Self::ResourceNotFound - | Self::ConsultationUnresolved - | Self::RepresentationNotFound => 404, + Self::ResourceNotFound | Self::ConsultationUnresolved => 404, Self::UnsupportedRepresentation => 406, Self::BodyTooLarge => 413, Self::UriTooLong => 414, @@ -176,7 +171,6 @@ impl ProblemCode { Self::InvalidFilter => "filter value is invalid", Self::CursorInvalid => "cursor is invalid for this query", Self::RepresentationInvalid => "representation selection is invalid", - Self::RepresentationNotFound => "the requested representation was not found", Self::MissingCredential => "a bearer access token is required", Self::InvalidCredential => "bearer access token validation failed", Self::ConsultationDenied => "the consultation is not permitted", diff --git a/crates/registry-relay-v2/src/semantics.rs b/crates/registry-relay-v2/src/semantics.rs index 3ba3c2aa1..81d7d1d1a 100644 --- a/crates/registry-relay-v2/src/semantics.rs +++ b/crates/registry-relay-v2/src/semantics.rs @@ -50,6 +50,7 @@ pub fn json_ld_context( let mut context = Map::new(); context.insert("@version".into(), json!(1.1)); context.insert("@vocab".into(), json!(registry.local_vocabulary)); + context.insert("xsd".into(), json!("http://www.w3.org/2001/XMLSchema#")); for field in [ "registryIdentifier", "schemaReference", @@ -61,23 +62,34 @@ pub fn json_ld_context( json!({"@id": format!("{core}{field}"), "@type": "@id"}), ); } - for field in [ - "recordIdentifier", - "revisionIdentifier", - "lifecycleState", - "recordedAt", - "domainData", - ] { - context.insert(field.into(), json!(format!("{core}{field}"))); + for field in ["recordIdentifier", "revisionIdentifier", "lifecycleState"] { + context.insert( + field.into(), + json!({"@id": format!("{core}{field}"), "@type": "xsd:string"}), + ); } + context.insert( + "recordedAt".into(), + json!({"@id": format!("{core}recordedAt"), "@type": "xsd:dateTime"}), + ); + context.insert("domainData".into(), json!("@nest")); for property in selected_properties(resource, selected) { context.insert( property.name.clone(), - json!({"@id": property.semantic_iri, "@nest": "domainData"}), + json!({ + "@id": property.semantic_iri, + "@nest": "domainData", + "@type": datatype_iri(property.data_type), + }), ); } - // Transport-only envelope members never acquire semantic meaning. - for field in ["data", "items", "pageInfo", "nextCursor", "meta"] { + // Record containers contribute their contents to the graph without + // becoming predicates of their own. Other transport-only members never + // acquire semantic meaning. + for field in ["data", "items"] { + context.insert(field.into(), json!("@graph")); + } + for field in ["pageInfo", "nextCursor", "meta"] { context.insert(field.into(), Value::Null); } json!({"@context": context}) @@ -163,6 +175,8 @@ fn record_schema( "authorityIdentifier", "recordedAt", "domainData" ], "properties": { + "@id": {"type": "string", "format": "uri"}, + "@type": {"const": resource.semantic_class}, "registryIdentifier": {"const": registry.registry_identifier}, "recordIdentifier": {"type": "string", "minLength": 1}, "revisionIdentifier": {"type": "string", "minLength": 1}, @@ -203,14 +217,20 @@ fn shacl( &require_codelist(registry, &resource.record_context.lifecycle_state_codelist).values; let lifecycle_constraint = shacl_in(lifecycle_values); let mut output = format!( - "@prefix sh: .\n@prefix xsd: .\n\n<{}shapes/{}> a sh:NodeShape ;\n sh:targetClass <{}> ;\n sh:closed true", + "@prefix rdf: .\n@prefix sh: .\n@prefix xsd: .\n\n<{}shapes/{}> a sh:NodeShape ;\n sh:targetClass <{}> ;\n sh:closed true ;\n sh:ignoredProperties ( rdf:type )", registry.local_vocabulary, resource.id, resource.semantic_class ); + for path in [ + "registryIdentifier", + "schemaReference", + "semanticModelReference", + "authorityIdentifier", + ] { + output.push_str(&format!( + " ;\n sh:property [ sh:path ; sh:nodeKind sh:IRI ; sh:minCount 1 ; sh:maxCount 1 ]" + )); + } for (path, datatype) in [ - ( - "registryIdentifier", - "http://www.w3.org/2001/XMLSchema#anyURI", - ), ( "recordIdentifier", "http://www.w3.org/2001/XMLSchema#string", @@ -220,15 +240,6 @@ fn shacl( "http://www.w3.org/2001/XMLSchema#string", ), ("lifecycleState", "http://www.w3.org/2001/XMLSchema#string"), - ("schemaReference", "http://www.w3.org/2001/XMLSchema#anyURI"), - ( - "semanticModelReference", - "http://www.w3.org/2001/XMLSchema#anyURI", - ), - ( - "authorityIdentifier", - "http://www.w3.org/2001/XMLSchema#anyURI", - ), ("recordedAt", "http://www.w3.org/2001/XMLSchema#dateTime"), ] { let controlled_values = if path == "lifecycleState" { @@ -358,7 +369,14 @@ mod tests { fn transport_envelope_is_null_in_context() { let context = json_ld_context(®istry(), &resource(), &["name".into()]); assert!(context["@context"]["meta"].is_null()); + assert_eq!(context["@context"]["data"], "@graph"); + assert_eq!(context["@context"]["items"], "@graph"); + assert_eq!(context["@context"]["domainData"], "@nest"); assert_eq!(context["@context"]["name"]["@nest"], "domainData"); + assert_eq!( + context["@context"]["name"]["@type"], + "http://www.w3.org/2001/XMLSchema#string" + ); } #[test] @@ -400,7 +418,18 @@ mod tests { schema["properties"]["domainData"]["properties"]["name"]["enum"], json!(["ONE", "TWO"]) ); + assert_eq!( + schema["properties"]["@id"], + json!({"type": "string", "format": "uri"}) + ); + assert_eq!( + schema["properties"]["@type"], + json!({"const": resource.semantic_class}) + ); let shacl = full_record_shacl(®istry, &resource); + assert!(shacl.contains("sh:targetClass ")); + assert!(shacl.contains("sh:ignoredProperties ( rdf:type )")); + assert!(shacl.contains("sh:nodeKind sh:IRI")); assert!(shacl.contains("sh:in ( \"ACTIVE\" \"RETIRED\" )")); assert!(shacl.contains("sh:in ( \"ONE\" \"TWO\" )")); } diff --git a/crates/registry-relay-v2/tests/acceptance_http.rs b/crates/registry-relay-v2/tests/acceptance_http.rs index 76af3b9bb..485b5ac10 100644 --- a/crates/registry-relay-v2/tests/acceptance_http.rs +++ b/crates/registry-relay-v2/tests/acceptance_http.rs @@ -13,6 +13,8 @@ use bytes::Bytes; use futures::stream; use http::header::{AUTHORIZATION, CACHE_CONTROL, CONTENT_TYPE, ETAG, VARY}; use http::{HeaderMap, HeaderName, HeaderValue, Method, Request, StatusCode}; +use jsonschema::{Draft, JSONSchema}; +use oxjsonld::JsonLdParser; use registry_platform_audit::{ AuditChainHasher, AuditEnvelope, AuditError, AuditSink, ChainState, JsonlFileSink, }; @@ -28,9 +30,18 @@ use registry_platform_testing::{ use registry_relay_v2::artifacts::generate_artifacts; use registry_relay_v2::audit::RelayAudit; use registry_relay_v2::auth::RelayAuthenticator; -use registry_relay_v2::compiler::{compile_contract_with_governed_files, GovernedFileSet}; +use registry_relay_v2::compiler::{ + classification_inventory_digest, compile_contract, compile_contract_with_governed_files, + GovernedFileSet, +}; use registry_relay_v2::contract::{RegistryContract, RelayRuntime}; -use registry_relay_v2::identification::parse_classification_review_yaml; +use registry_relay_v2::fixture_contract::{ + parse_journey, FixtureAuthorization as AuthorizationFixture, FixtureJourney as Journey, + FixtureMethod, FixtureStep as JourneyStep, +}; +use registry_relay_v2::identification::{ + parse_classification_review_yaml, render_classification_review_yaml, +}; use registry_relay_v2::model::{ CompileProfile, ObservedColumn, ObservedSourceSchema, ObservedView, }; @@ -38,7 +49,6 @@ use registry_relay_v2::server::{ router, AlignmentMetadata, InstitutionMetadata, QuotaConfig, RelayService, ServiceMetadata, }; use registry_relay_v2::sqlite_runtime::{RuntimeSourceBinding, SqliteRuntime, SqliteRuntimeLimits}; -use serde::Deserialize; use serde_json::{json, Value}; use tempfile::TempDir; use tower::ServiceExt as _; @@ -49,83 +59,10 @@ const ACCEPTANCE_ROOT: &str = concat!( ); const PROJECTS: [&str; 3] = ["social-assistance", "business-registry", "civil-event"]; -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -struct Journey { - schema_version: String, - registry: String, - #[serde(default)] - authorizations: BTreeMap, - steps: Vec, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -struct AuthorizationFixture { - principal: String, - scopes: BTreeSet, - #[serde(default)] - claims: BTreeMap, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -struct JourneyStep { - id: String, - #[serde(default)] - authorization_fixture: Option, - request: JourneyRequest, - expect: JourneyExpectation, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -struct JourneyRequest { - method: String, - path: String, - #[serde(default)] - headers: BTreeMap, - #[serde(default)] - query: BTreeMap, - #[serde(default)] - body: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -#[serde(rename_all = "camelCase")] -struct JourneyExpectation { - status: u16, - #[serde(default)] - capability_patterns: Vec, - #[serde(default)] - absent_capability_patterns: Vec, - #[serde(default)] - item_count: Option, - #[serde(default)] - next_cursor: Option, - #[serde(default)] - registry_core_required: bool, - #[serde(default)] - domain_data_keys: Vec, - #[serde(default)] - record_identifier: Option, - #[serde(default)] - cache: Option, - #[serde(default)] - code: Option, - #[serde(default)] - route_absent: bool, - #[serde(default)] - equivalence_class: Option, - #[serde(default)] - absent_everywhere: Vec, - #[serde(default)] - records_equivalent_to: Option, - #[serde(default)] - body_empty: bool, - #[serde(default)] - etag_same_as: Option, +#[derive(Default)] +struct ResponseContractCoverage { + json_records: usize, + json_ld_records: usize, } struct ProjectHarness { @@ -206,10 +143,7 @@ async fn all_three_registry_http_journeys_use_the_real_router() { .is_none_or(|selected| selected == *project) }) { let mut harness = ProjectHarness::open(project).await; - let journey: Journey = serde_norway::from_slice( - &fs::read(project_root(project).join("expected-http.yaml")).expect("journey reads"), - ) - .expect("journey parses"); + let journey = project_journey(project); assert_eq!( journey.schema_version, "relay.registrystack.org/http-journey/v1alpha1" @@ -239,6 +173,7 @@ async fn all_three_registry_http_journeys_use_the_real_router() { let mut equivalence_classes = BTreeMap::new(); let mut response_documents = BTreeMap::new(); let mut etags = BTreeMap::new(); + let mut contract_coverage = ResponseContractCoverage::default(); for step in journey.steps { let request = harness.request_with_observations( &step, @@ -272,6 +207,14 @@ async fn all_three_registry_http_journeys_use_the_real_router() { assert_expectations(project, &step, &headers, &body, &mut equivalence_classes); if !body.is_empty() { let document: Value = serde_json::from_slice(&body).expect("response is JSON"); + validate_response_contracts( + &harness, + project, + &step, + &headers, + &document, + &mut contract_coverage, + ); if let Some(reference) = &step.expect.records_equivalent_to { let expected = response_documents .get(reference) @@ -286,6 +229,14 @@ async fn all_three_registry_http_journeys_use_the_real_router() { response_documents.insert(step.id.clone(), document); } } + assert!( + contract_coverage.json_records > 0, + "{project} must validate an ordinary JSON Record against its generated schema" + ); + assert!( + contract_coverage.json_ld_records > 0, + "{project} must validate a JSON-LD Record against its generated schema" + ); shutdown_tx.send(()).expect("loopback server is running"); tokio::time::timeout(Duration::from_secs(5), server) .await @@ -371,13 +322,21 @@ async fn malformed_disclosed_property_type_and_requiredness_fail_closed() { assert_ne!(valid_recorded_at, original); let wrong_type = valid_recorded_at.replacen(") STRICT;", ");", 1).replacen( - "'Invalid Fixture Enterprise'", - "X'FF'", + "'Invalid Fixture Enterprise', 'Invalid Fixture Enterprise'", + "X'FF', X'FF'", 1, ); let missing_required = valid_recorded_at - .replacen("legal_name TEXT NOT NULL", "legal_name TEXT", 1) - .replacen("'Invalid Fixture Enterprise'", "NULL", 1); + .replacen( + "public_legal_name TEXT NOT NULL", + "public_legal_name TEXT", + 1, + ) + .replacen( + "'Invalid Fixture Enterprise', 'Invalid Fixture Enterprise'", + "'Invalid Fixture Enterprise', NULL", + 1, + ); for (case, fixture_sql) in [ ("wrong property type", wrong_type), @@ -487,11 +446,7 @@ async fn readiness_fails_value_free_for_missing_replaced_and_drifted_sources() { #[tokio::test] async fn social_live_update_is_consistent_and_truthfully_unversioned() { let harness = ProjectHarness::open("social-assistance").await; - let journey: Journey = serde_norway::from_slice( - &fs::read(project_root("social-assistance").join("expected-http.yaml")) - .expect("journey reads"), - ) - .expect("journey parses"); + let journey = project_journey("social-assistance"); let step = journey .steps .iter() @@ -563,11 +518,7 @@ async fn social_live_update_is_consistent_and_truthfully_unversioned() { #[tokio::test] async fn trusted_purpose_and_row_binding_refusals_use_only_verified_claims() { let harness = ProjectHarness::open("social-assistance").await; - let journey: Journey = serde_norway::from_slice( - &fs::read(project_root("social-assistance").join("expected-http.yaml")) - .expect("journey reads"), - ) - .expect("journey parses"); + let journey = project_journey("social-assistance"); for (step_id, status) in [ ("missing-purpose", StatusCode::FORBIDDEN), ("wrong-purpose", StatusCode::FORBIDDEN), @@ -660,11 +611,7 @@ async fn audit_terminal_failure_discards_held_record_bytes() { #[tokio::test] async fn real_jwt_path_rejects_malformed_audience_time_and_expired_tokens() { let harness = ProjectHarness::open("social-assistance").await; - let journey: Journey = serde_norway::from_slice( - &fs::read(project_root("social-assistance").join("expected-http.yaml")) - .expect("journey reads"), - ) - .expect("journey parses"); + let journey = project_journey("social-assistance"); let step = journey .steps .iter() @@ -815,11 +762,7 @@ async fn real_jwt_path_rejects_malformed_audience_time_and_expired_tokens() { #[tokio::test] async fn operation_bound_metadata_is_no_store_and_links_only_visible_artifacts() { let harness = ProjectHarness::open("social-assistance").await; - let journey: Journey = serde_norway::from_slice( - &fs::read(project_root("social-assistance").join("expected-http.yaml")) - .expect("journey reads"), - ) - .expect("journey parses"); + let journey = project_journey("social-assistance"); let step = journey .steps .iter() @@ -994,10 +937,7 @@ async fn insufficient_scope_and_unknown_data_surfaces_are_indistinguishable() { Some(Arc::clone(&sink) as Arc), ) .await; - let journey: Journey = serde_norway::from_slice( - &fs::read(project_root("civil-event").join("expected-http.yaml")).expect("journey reads"), - ) - .expect("journey parses"); + let journey = project_journey("civil-event"); let read_fixture = journey .authorizations .get("civil-registrar-ex-a") @@ -1142,11 +1082,7 @@ async fn list_uri_refusal_uses_the_resolved_access_context() { #[tokio::test] async fn lookup_body_collection_obeys_the_request_deadline() { let harness = ProjectHarness::open("social-assistance").await; - let journey: Journey = serde_norway::from_slice( - &fs::read(project_root("social-assistance").join("expected-http.yaml")) - .expect("journey reads"), - ) - .expect("journey parses"); + let journey = project_journey("social-assistance"); let step = journey .steps .iter() @@ -1221,7 +1157,7 @@ fn assert_expectations( equivalence_classes: &mut BTreeMap, ) { let label = format!("{project}/{}", step.id); - if step.expect.body_empty { + if step.expect.body_empty.unwrap_or(false) { assert!(body.is_empty(), "{label} body must be empty"); return; } @@ -1234,7 +1170,7 @@ fn assert_expectations( "{label} code" ); } - if step.expect.route_absent { + if step.expect.route_absent.unwrap_or(false) { assert_eq!( document.get("code").and_then(Value::as_str), Some("resource.not_found"), @@ -1276,23 +1212,24 @@ fn assert_expectations( .get("items") .and_then(Value::as_array) .map(Vec::len), - Some(count), + Some(count as usize), "{label} item count" ); } if let Some(expectation) = &step.expect.next_cursor { let cursor = document.pointer("/pageInfo/nextCursor"); match expectation.as_str() { - "non-null" => assert!( + Some("non-null") => assert!( cursor.is_some_and(|value| !value.is_null()), "{label} cursor" ), - "null" => assert!(cursor.is_some_and(Value::is_null), "{label} cursor"), - value => panic!("{label} has unsupported nextCursor expectation {value}"), + Some("null") => assert!(cursor.is_some_and(Value::is_null), "{label} cursor"), + Some(value) => panic!("{label} has unsupported nextCursor expectation {value}"), + None => panic!("{label} nextCursor expectation must be a string"), } } let records = response_records(&document); - if step.expect.registry_core_required { + if step.expect.registry_core_required.unwrap_or(false) { assert!(!records.is_empty(), "{label} must contain a Record"); for record in &records { for key in [ @@ -1329,6 +1266,40 @@ fn assert_expectations( assert_eq!(actual, expected, "{label} disclosed domain properties"); } } + if !step.expect.domain_data_values.is_empty() { + assert!( + step.expect.domain_data_values.len() <= 64, + "{label} has too many exact domain-value expectations" + ); + assert!(!records.is_empty(), "{label} must contain domain data"); + for (property, expected) in &step.expect.domain_data_values { + assert!( + property.len() <= 128, + "{label} has an overlong domain-value expectation name" + ); + assert!( + matches!( + expected, + Value::Bool(_) | Value::Number(_) | Value::String(_) + ), + "{label} domain-value expectations must be non-null JSON scalars" + ); + assert!( + step.expect.domain_data_keys.contains(property), + "{label} exact domain-value expectation must be closed by domainDataKeys" + ); + for record in &records { + let actual = record + .get("domainData") + .and_then(Value::as_object) + .and_then(|domain| domain.get(property)); + assert!( + actual == Some(expected), + "{label} returned the wrong governed value for {property}" + ); + } + } + } if let Some(identifier) = &step.expect.record_identifier { assert_eq!( document @@ -1399,6 +1370,7 @@ fn normalized_records(document: &Value) -> Vec { let mut record = record.clone(); if let Some(object) = record.as_object_mut() { object.remove("@id"); + object.remove("@type"); } record }) @@ -1416,6 +1388,327 @@ fn response_records(document: &Value) -> Vec<&Value> { } } +fn validate_response_contracts( + harness: &ProjectHarness, + project: &str, + step: &JourneyStep, + headers: &HeaderMap, + document: &Value, + coverage: &mut ResponseContractCoverage, +) { + let records = response_records(document); + if records.is_empty() { + return; + } + let media_type = headers + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.split(';').next()) + .expect("Record response has a content type"); + let json_ld = match media_type { + "application/json" => false, + "application/ld+json" => true, + _ => panic!( + "{project}/{} returned an unsupported Record media type", + step.id + ), + }; + + let operation_identifier = document + .pointer("/meta/operationIdentifier") + .and_then(Value::as_str) + .expect("Record response names its compiled operation"); + let representation_identifier = document + .pointer("/meta/representation") + .and_then(Value::as_str) + .expect("Record response names its selected representation"); + let matching_bindings = harness + .service + .artifacts + .operation_bindings + .iter() + .filter(|binding| { + binding.operation_identifier == operation_identifier + && binding.representation_identifier == representation_identifier + }) + .collect::>(); + assert_eq!( + matching_bindings.len(), + 1, + "{project}/{} must resolve one exact operation and representation binding", + step.id + ); + let binding = matching_bindings[0]; + + for record in records { + let schema_reference = record + .get("schemaReference") + .and_then(Value::as_str) + .expect("Record carries its exact permitted-representation schema reference"); + assert_eq!( + document + .pointer("/meta/links/schema") + .and_then(Value::as_str), + Some(schema_reference), + "{project}/{} metadata and Record must name the same schema", + step.id + ); + + let matching_schemas = harness + .service + .artifacts + .artifacts + .iter() + .filter(|artifact| artifact.media_type == "application/schema+json") + .filter_map(|artifact| { + let schema: Value = serde_json::from_slice(&artifact.content).ok()?; + (schema.get("$id").and_then(Value::as_str) == Some(schema_reference)) + .then_some((artifact, schema)) + }) + .collect::>(); + assert_eq!( + matching_schemas.len(), + 1, + "{project}/{} must resolve exactly one generated permitted-representation schema", + step.id + ); + let (schema_artifact, schema) = &matching_schemas[0]; + let validator = JSONSchema::options() + .with_draft(Draft::Draft202012) + .should_validate_formats(true) + .compile(schema) + .unwrap_or_else(|_| { + panic!( + "{project}/{} generated permitted-representation schema must compile", + step.id + ) + }); + assert!( + validator.is_valid(record), + "{project}/{} Record must validate against its exact generated permitted-representation schema", + step.id + ); + + assert_eq!( + binding.representation_schema_path, schema_artifact.path, + "{project}/{} schema must belong to the exact operation and representation", + step.id + ); + let shacl_path = &binding.representation_shacl_path; + let shacl_artifact = harness + .service + .artifacts + .get(shacl_path) + .expect("the exact response binding carries its generated SHACL artifact"); + assert_eq!(shacl_artifact.media_type, "text/turtle"); + assert!( + !shacl_artifact.content.is_empty(), + "{project}/{} exact generated SHACL artifact must not be empty", + step.id + ); + + if json_ld { + coverage.json_ld_records += 1; + } else { + coverage.json_records += 1; + } + } + + if json_ld { + validate_json_ld_graph(harness, project, step, document, binding); + } +} + +fn validate_json_ld_graph( + harness: &ProjectHarness, + project: &str, + step: &JourneyStep, + document: &Value, + binding: ®istry_relay_v2::artifacts::OperationArtifactBindings, +) { + let context_artifact = harness + .service + .artifacts + .get(&binding.context_path) + .expect("the exact response binding carries its generated JSON-LD context"); + let context_document: Value = serde_json::from_slice(&context_artifact.content) + .expect("generated JSON-LD context parses"); + let mut expanded_document = document.clone(); + expanded_document["@context"] = context_document["@context"].clone(); + let raw = serde_json::to_string(&expanded_document).expect("JSON-LD response serializes"); + let parser = JsonLdParser::new() + .with_base_iri(&harness.service.registry.base_uri) + .expect("Registry base IRI is valid"); + let quads = parser + .for_slice(&raw) + .map(|quad| { + quad.unwrap_or_else(|error| { + panic!( + "{project}/{} generated context must expand the actual response: {error}", + step.id + ) + }) + .to_string() + }) + .collect::>(); + assert!( + !quads.is_empty(), + "{project}/{} JSON-LD response must produce an RDF graph", + step.id + ); + + let resource = harness + .service + .registry + .resources + .iter() + .find(|resource| { + resource + .operations + .iter() + .any(|operation| operation.identifier == binding.operation_identifier) + }) + .expect("compiled operation belongs to one resource"); + let representation = resource + .operations + .iter() + .find(|operation| operation.identifier == binding.operation_identifier) + .and_then(|operation| { + operation + .representations + .iter() + .find(|representation| representation.id == binding.representation_identifier) + }) + .expect("compiled operation carries the selected representation"); + let shacl = std::str::from_utf8( + &harness + .service + .artifacts + .get(&binding.representation_shacl_path) + .expect("bound SHACL artifact exists") + .content, + ) + .expect("generated SHACL is UTF-8"); + assert!(shacl.contains(&format!("sh:targetClass <{}>", resource.semantic_class))); + assert!(shacl.contains("sh:ignoredProperties ( rdf:type )")); + + for record in response_records(document) { + let subject = record + .get("@id") + .and_then(Value::as_str) + .expect("JSON-LD Record carries @id"); + assert_quad( + &quads, + subject, + "http://www.w3.org/1999/02/22-rdf-syntax-ns#type", + &format!("<{}>", resource.semantic_class), + project, + &step.id, + ); + for field in [ + "registryIdentifier", + "schemaReference", + "semanticModelReference", + "authorityIdentifier", + ] { + let object = record[field] + .as_str() + .expect("Registry Core IRI is a string"); + let predicate = format!("https://id.registrystack.org/vocab/core/{field}"); + assert_quad( + &quads, + subject, + &predicate, + &format!("<{object}>"), + project, + &step.id, + ); + assert!(shacl.contains(&format!("sh:path <{predicate}> ; sh:nodeKind sh:IRI"))); + } + for (field, datatype) in [ + ( + "recordIdentifier", + "http://www.w3.org/2001/XMLSchema#string", + ), + ( + "revisionIdentifier", + "http://www.w3.org/2001/XMLSchema#string", + ), + ("lifecycleState", "http://www.w3.org/2001/XMLSchema#string"), + ("recordedAt", "http://www.w3.org/2001/XMLSchema#dateTime"), + ] { + let predicate = format!("https://id.registrystack.org/vocab/core/{field}"); + assert_typed_quad(&quads, subject, &predicate, datatype, project, &step.id); + assert!(shacl.contains(&format!("sh:path <{predicate}> ; sh:datatype <{datatype}>"))); + } + let domain_data = record["domainData"] + .as_object() + .expect("Record domainData is an object"); + for property_name in domain_data.keys() { + let property = resource + .properties + .iter() + .find(|property| property.name == *property_name) + .expect("disclosed property is compiled"); + assert!(representation.selectable_properties.contains(property_name)); + let datatype = registry_relay_v2::semantics::datatype_iri(property.data_type); + assert_typed_quad( + &quads, + subject, + &property.semantic_iri, + datatype, + project, + &step.id, + ); + assert!(shacl.contains(&format!( + "sh:path <{}> ; sh:datatype <{datatype}>", + property.semantic_iri + ))); + } + } +} + +fn assert_quad( + quads: &[String], + subject: &str, + predicate: &str, + object: &str, + project: &str, + step: &str, +) { + let expected = format!("<{subject}> <{predicate}> {object}"); + assert!( + quads.iter().any(|quad| quad.contains(&expected)), + "{project}/{step} expanded graph is missing a required IRI statement" + ); +} + +fn assert_typed_quad( + quads: &[String], + subject: &str, + predicate: &str, + datatype: &str, + project: &str, + step: &str, +) { + let subject_predicate = format!("<{subject}> <{predicate}>"); + let datatype_marker = format!("^^<{datatype}>"); + let plain_string = datatype == "http://www.w3.org/2001/XMLSchema#string"; + assert!( + quads.iter().any(|quad| { + let Some((_, object)) = quad.split_once(&subject_predicate) else { + return false; + }; + if plain_string { + object.trim_start().starts_with('"') + } else { + object.contains(&datatype_marker) + } + }), + "{project}/{step} expanded graph is missing predicate {predicate} with datatype {datatype}" + ); +} + impl ProjectHarness { async fn open(project: &str) -> Self { Self::open_with_audit(project, None).await @@ -1497,7 +1790,26 @@ impl ProjectHarness { }) .collect(), }]; - let governed = governed_files(&root, &contract); + let mut governed = governed_files(&root, &contract); + if accept_fixture_fingerprint { + let inventory = compile_contract(&contract, &observed, CompileProfile::Production) + .expect("synthetic fixture inventory compiles"); + let inventory_digest = classification_inventory_digest(&inventory) + .expect("synthetic fixture inventory digests"); + let review_path = contract.classifications.provenance_ref.clone(); + let mut review = parse_classification_review_yaml( + governed + .get(&review_path) + .expect("classification review is governed"), + ) + .expect("classification review parses"); + review.classification_inventory_digest = inventory_digest; + governed.insert( + review_path, + render_classification_review_yaml(&review) + .expect("synthetic fixture review renders"), + ); + } let compiled = Arc::new( compile_contract_with_governed_files( &contract, @@ -1654,25 +1966,21 @@ impl ProjectHarness { url.push('?'); url.push_str(&serializer.finish()); } - let method = step - .request - .method - .parse::() - .expect("journey method is valid"); - let body = step - .request - .body - .as_ref() - .map(|selectors| { - serde_json::to_vec(&json!({"selectors": selectors})).expect("body serializes") - }) - .unwrap_or_default(); + let method = match step.request.method { + FixtureMethod::Get => Method::GET, + FixtureMethod::Post => Method::POST, + }; + let body = if step.request.body.is_empty() { + Vec::new() + } else { + serde_json::to_vec(&json!({"selectors": &step.request.body})).expect("body serializes") + }; let mut request = Request::builder() .method(method) .uri(url) .body(Body::from(body)) .expect("request builds"); - if step.request.body.is_some() { + if !step.request.body.is_empty() { request.headers_mut().insert( CONTENT_TYPE, "application/json".parse().expect("content type"), @@ -1897,6 +2205,12 @@ fn project_root(project: &str) -> PathBuf { Path::new(ACCEPTANCE_ROOT).join(project) } +fn project_journey(project: &str) -> Journey { + let bytes = fs::read(project_root(project).join("expected-http.yaml")).expect("journey reads"); + let yaml = std::str::from_utf8(&bytes).expect("journey is UTF-8 YAML"); + parse_journey(yaml).expect("journey parses through the production fixture contract") +} + fn yaml_scalar(value: &Value) -> Option { match value { Value::String(value) => Some(value.clone()), diff --git a/crates/registry-relay-v2/tests/representation_http.rs b/crates/registry-relay-v2/tests/representation_http.rs index 346f04824..8563afee4 100644 --- a/crates/registry-relay-v2/tests/representation_http.rs +++ b/crates/registry-relay-v2/tests/representation_http.rs @@ -333,26 +333,21 @@ async fn representation_selection_authenticates_then_authorizes_the_exact_profil ( None, "caseworker", - StatusCode::UNAUTHORIZED, - "auth.missing_credential", - ), - ( - None, - "missing", StatusCode::NOT_FOUND, - "representation.not_found", + "resource.not_found", ), + (None, "missing", StatusCode::NOT_FOUND, "resource.not_found"), ( Some(limited.as_str()), "caseworker", - StatusCode::FORBIDDEN, - "consultation.denied", + StatusCode::NOT_FOUND, + "resource.not_found", ), ( Some(limited.as_str()), "missing", StatusCode::NOT_FOUND, - "representation.not_found", + "resource.not_found", ), ] { let uri = format!("/v2/resources/record/records/record-1?representation={representation}"); @@ -362,24 +357,37 @@ async fn representation_selection_authenticates_then_authorizes_the_exact_profil let records = sink.values(); assert!(records.iter().all(|event| event["phase"] == "refusal")); - assert_eq!( - records - .iter() - .filter(|event| event["representation"] == "caseworker") - .count(), - 2 - ); + assert!(records + .iter() + .all(|event| event.get("representation").is_none())); assert_eq!( records .iter() .filter(|event| event.get("representation").is_none()) .count(), - 3 + 5 ); let audit_wire = serde_json::to_string(&records).expect("audit serializes"); assert!(!audit_wire.contains("not-a-jwt")); } +#[tokio::test] +async fn oversized_uri_still_conceals_exact_representation_authorization() { + let harness = Harness::open(None, Arc::new(RecordingSink::default())).await; + let limited = harness.token(&["registry:limited"], "review", "area-a"); + let padding = "x".repeat(20_000); + + for representation in ["caseworker", "missing"] { + let uri = format!( + "/v2/resources/record/records/record-1?representation={representation}&padding={padding}" + ); + let (status, _, body) = harness + .send(Method::GET, &uri, Some(&limited), None, &[]) + .await; + assert_problem(status, &body, StatusCode::NOT_FOUND, "resource.not_found"); + } +} + #[tokio::test] async fn preflight_refusals_do_not_reach_source_and_attempt_audit_precedes_source_access() { let sink = Arc::new(RecordingSink::default()); @@ -402,7 +410,7 @@ async fn preflight_refusals_do_not_reach_source_and_attempt_audit_precedes_sourc ( "/v2/resources/record/records?representation=missing", StatusCode::NOT_FOUND, - "representation.not_found", + "resource.not_found", ), ( "/v2/resources/record/records?representation=limited&fields=secretValue", @@ -628,8 +636,8 @@ async fn malformed_registry_core_fails_closed_and_list_release_is_atomic() { assert_problem( status, &body, - StatusCode::NOT_FOUND, - "consultation.unresolved", + StatusCode::SERVICE_UNAVAILABLE, + "source.unavailable", ); let records = sink.values(); @@ -640,7 +648,7 @@ async fn malformed_registry_core_fails_closed_and_list_release_is_atomic() { assert_eq!(terminal.len(), 3); assert_eq!(terminal[0]["outcome"], "source-failed"); assert_eq!(terminal[1]["outcome"], "source-failed"); - assert_eq!(terminal[2]["outcome"], "unresolved"); + assert_eq!(terminal[2]["outcome"], "source-failed"); let audit_wire = serde_json::to_string(&records).expect("audit serializes"); for source_value in [ "record-1a", @@ -737,8 +745,8 @@ async fn transforms_are_bounded_value_free_and_terminal_audit_gates_exact_bytes( assert_problem( status, &body, - StatusCode::NOT_FOUND, - "consultation.unresolved", + StatusCode::SERVICE_UNAVAILABLE, + "source.unavailable", ); assert!(!String::from_utf8(body) .expect("problem UTF-8") From 06c52e7e05297952a23f11cb0b5002e74c40db04 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 10 Aug 2026 15:26:14 +0700 Subject: [PATCH 12/24] docs(relay): align semantic and acceptance contracts Signed-off-by: Jeremi Joslin --- .../site/src/content/docs/configure/relay.mdx | 13 ++- .../governed-registry-publication.mdx | 6 +- .../relay-semantics-and-disclosure.mdx | 43 +++++-- docs/site/src/content/docs/operate/relay.mdx | 18 ++- .../publish-governed-sqlite-registry.mdx | 12 +- products/relay-v2/CONCEPT.md | 39 ++++--- products/relay-v2/DEFINITION-OF-DONE.md | 22 ++-- products/relay-v2/IMPLEMENTATION.md | 55 +++++---- .../business-registry/expected-http.yaml | 4 +- .../acceptance/civil-event/expected-http.yaml | 17 ++- .../classification-review-rationale.md | 3 +- .../governance/classification-review.yaml | 2 +- .../acceptance/civil-event/registry.yaml | 11 +- .../semantics/local-vocabulary.yaml | 2 + .../social-assistance/expected-http.yaml | 12 +- .../contracts/acceptance-scenario-matrix.yaml | 18 +-- .../contracts/generated-baselines.yaml | 106 +++++++++--------- .../contracts/security-invariant-matrix.yaml | 35 +++--- .../relay-v2/scripts/test_validate_product.py | 70 ++++++++++++ products/relay-v2/scripts/validate_product.py | 24 ++++ 20 files changed, 354 insertions(+), 158 deletions(-) diff --git a/docs/site/src/content/docs/configure/relay.mdx b/docs/site/src/content/docs/configure/relay.mdx index aa7575830..03d9aff58 100644 --- a/docs/site/src/content/docs/configure/relay.mdx +++ b/docs/site/src/content/docs/configure/relay.mdx @@ -128,8 +128,9 @@ Its profile defines the maximum property set that can reach `domainData`. Callers may omit `representation` to select the declared default, or supply one named representation. Relay authorizes the supplied representation exactly as requested. -An unknown representation, invalid bearer, or denial never falls back to the default or another -representation. +A syntactically valid unknown name and a scope-hidden name receive the same +`404 resource.not_found` response. An invalid bearer is `401`. None falls back to the default or +another representation. The `fields` parameter can only select a non-empty subset of the selected representation's public properties. It cannot add a property, select a source column, change a transform, weaken handling, or bypass @@ -157,8 +158,12 @@ Relay supports only two compiled, deterministic transforms: - `date-precision` accepts a canonical `date` or `date-time` and emits a `year` or `year-month` output property with its own type, semantic term, and classification. -Invalid, noncanonical, oversized, or required missing transform input fails without releasing a -value. +Transforms are response-only. A transformed property cannot be a list filter or fixed-order key, +because the SQLite query would otherwise compare its undisclosed raw input. Expose a separately +reviewed pre-derived view column when the derived value must be queryable. + +Invalid, noncanonical, oversized, or required missing transform input fails the complete selected +Record as value-free `503 source.unavailable` without releasing a value. Hashing, pseudonyms, encryption, regular-expression replacement, caller-defined masks or expressions, dynamic per-request masking, and a free-form policy engine are not supported. diff --git a/docs/site/src/content/docs/explanation/governed-registry-publication.mdx b/docs/site/src/content/docs/explanation/governed-registry-publication.mdx index 52df83180..03e0e4560 100644 --- a/docs/site/src/content/docs/explanation/governed-registry-publication.mdx +++ b/docs/site/src/content/docs/explanation/governed-registry-publication.mdx @@ -30,7 +30,7 @@ Each successful Record preserves Registry Core: Registry and Record identifiers, lifecycle state, Authority, recorded time, response-schema reference, and semantic-model reference. Only `domainData` varies by representation and requester field subset. The pair `(registryIdentifier, recordIdentifier)` remains authoritative when JSON for Linked Data -(JSON-LD) adds a derived `@id`. +(JSON-LD) adds a derived `@id` and the resource semantic class as `@type`. ## Compile a complete reviewed agreement @@ -57,6 +57,8 @@ The profile is the maximum published-property set for that response. Callers choose the default by omitting `representation`, or request a supplied representation by name. Relay authorizes the exact choice and never falls back when it is unknown or denied. +A syntactically valid unknown name and a scope-hidden name share the generic +`404 resource.not_found` response, so callers cannot enumerate the finite representation map. `fields` runs after representation selection and can only narrow `domainData` within that profile. It cannot introduce SQL, source columns, joins, filters, ordering, expressions, transformations, or new authorization. @@ -88,6 +90,8 @@ operations, or response signing. Relay supports two fixed transforms under the contract: partial strings use the `***` marker, and date precision produces typed `year` or `year-month` output. It does not offer caller-defined masks, hashes, pseudonyms, encryption, or transform expressions. +An invalid selected source row or transform input fails the complete response as value-free +`503 source.unavailable`; Relay never treats invalid source data as a normal unresolved lookup. ## Make records and artifacts visible together diff --git a/docs/site/src/content/docs/explanation/relay-semantics-and-disclosure.mdx b/docs/site/src/content/docs/explanation/relay-semantics-and-disclosure.mdx index af6dde766..302541815 100644 --- a/docs/site/src/content/docs/explanation/relay-semantics-and-disclosure.mdx +++ b/docs/site/src/content/docs/explanation/relay-semantics-and-disclosure.mdx @@ -95,29 +95,48 @@ The request parameter `representation` selects a named representation. When absent, Relay uses the declared default. When present, Relay authorizes that exact representation: an invalid bearer, denied request, or unknown name does not fall back to another representation. +A syntactically valid unknown name and a scope-hidden name share the generic +`404 resource.not_found` response, so the finite representation map is not enumerable. After selection, `fields` may request only a non-empty subset of that representation's properties. -It cannot select a source column, switch profiles, bypass a row boundary, +It cannot select a source column, switch profiles, change a transform, bypass a row boundary, or lower the compiled handling, audit, quota, metadata, or cache controls. This is requester minimization, not dynamic per-request masking or attribute authorization. Relay has no free-form policy engine or arbitrary expression language. -The operator-only full JSON Schema and SHACL shape validate every -`sourceRequired` property and mandatory Record-context binding in the reviewed -view. Relay does not coerce or partially release an invalid selected row. For a -sensitive exact lookup, a missing Record, ambiguous result, and hidden row share -the same unresolved response so the result does not reveal which condition -occurred. An invalid selected source Record fails closed as -`503 source.unavailable` without releasing any part of the Record. - -Relay performs no runtime masking or value transformation. When a Registry -needs a masked, generalized, or precision-reduced value, its reviewed source -view must expose that pre-derived value as a separately classified property. +## Apply only bounded transforms + +The transform set is deliberately finite and deterministic. +`partial-string` produces the Relay-owned marker `***` with a bounded Unicode-scalar prefix or +suffix. +If the input has no more characters than the configured reveal length, the output is `***` alone. +`date-precision` turns a canonical `date` or `date-time` into a `year` or `year-month` property. +The transformed property has its own semantic term, output type, and classification. + +The operator-only full JSON Schema and SHACL shape validate every `sourceRequired` property and +mandatory Record-context binding in the reviewed view. +Relay does not coerce or partially release an invalid selected row. +For a sensitive exact lookup, a missing Record, ambiguous result, and hidden row share the same +unresolved response so the result does not reveal which condition occurred. +Invalid, noncanonical, oversized, incompatible, or required missing transform input fails the +complete selected Record as value-free `503 source.unavailable` without releasing source values. +Optional null input omits the transformed property. + +Relay does not provide hashing, pseudonyms, encryption, regular-expression replacement, geographic +or numeric transformations, codelist remapping, caller-defined masks, or dynamic masking policy. +A public representation cannot use a transform to conceal a non-public source column. It must read +a reviewed pre-derived public value from the SQLite view. +Transformed properties also cannot be list filters or fixed-order keys. Queryable derived values +must be separate reviewed pre-derived source properties, so filters and ordering never operate on +an undisclosed transform input. ## Generate semantics without pretending equivalence Relay generates a local JSON for Linked Data (JSON-LD) vocabulary and context, JSON Schema, SHACL, codelist schemas, capability metadata, and representation-specific artifacts. +The context expands response `data` and `items` as RDF graph containers, aliases `domainData` to +JSON-LD `@nest`, emits the resource semantic class as `@type`, and applies the same IRI and XML +Schema datatypes required by the operation-bound SHACL shape. An institution can add a reviewed and digest-pinned mapping to an external vocabulary with an explicit exact, close, broad, narrow, or related relation. Relay does not infer that relation or fetch remote vocabulary content while serving a request. diff --git a/docs/site/src/content/docs/operate/relay.mdx b/docs/site/src/content/docs/operate/relay.mdx index 42073576d..d409c3c07 100644 --- a/docs/site/src/content/docs/operate/relay.mdx +++ b/docs/site/src/content/docs/operate/relay.mdx @@ -30,9 +30,12 @@ administrative trust boundary. Prepare a dedicated Unix service identity, a private listener behind Transport Layer Security (TLS) termination, a sealed package, the matching snapshot or live read-only source, an audit -location, independent audit and cursor integrity keys, and a token issuer for protected +location, independent audit-integrity and cursor-encryption keys, and a token issuer for protected representations. Do not place the package, source, secret, or audit path in a shared writable directory. +For snapshot mode, make the SQLite file immutable outside Relay, preferably with a read-only mount. +Relay verifies its captured digest before and after every statement, but a process cannot exclude a +privileged writer that changes and restores bytes entirely between both checks. Relay validates trusted path components before use. They must not be symbolic links or writable by group or world, and must be owned by root or the @@ -63,6 +66,14 @@ quotas: {requestsPerMinute: 120, burst: 20} A package with a protected representation needs the configured issuer at startup. The issuer's verified claims may establish scopes, purpose, and row authority, but cannot enable an operation or representation the package did not compile. +A syntactically valid unknown representation and a valid principal without the selected +representation scope receive the same concealed `404 resource.not_found` response. Relay does not +fall back to a less restrictive representation. + +Relay authenticates and encrypts the complete cursor payload with a fresh nonce. The payload binds +the source and contract revisions, operation, representation, disclosure profile, filters, fixed +order, selected fields, authorization context, and expiry. Treat cursors as opaque continuation +tokens even though they contain no plaintext filter or order values. `relay serve --runtime ` opens only the sealed package at `packagePath`. It rejects unsafe paths, a package inventory mismatch, source-schema drift, missing mandatory audit @@ -117,7 +128,8 @@ listener directly. ## Retain value-free audit evidence For every data operation, including anonymous public access, Relay persists an attempt event before -SQLite access and a terminal event before it releases the exact response bytes. +SQLite access and a terminal release, unresolved, or source-failed event before returning. +The release event covers the exact response bytes. An audit failure blocks source access or withholds the response. Audit binds the Registry, resource, operation, representation, disclosure profile, selected @@ -150,5 +162,5 @@ Rollback activates a complete prior package only with its compatible source and | --- | --- | --- | | Startup refuses a path | A component is a symlink, has the wrong owner, or is writable | Move the deployment to trusted Unix paths and correct ownership and modes. | | Startup reports a package or schema error | The package, source, or review revision does not match | Stop deployment and return the change to the authoring workflow. | -| A protected representation is denied | The issuer or token does not satisfy that representation's exact access rule | Correct the issuer or caller authority. Do not expose a weaker representation as fallback. | +| A protected representation returns `404 resource.not_found` | The issuer or token does not satisfy that representation's exact scope | Correct the issuer or caller authority. Do not expose a weaker representation as fallback. | | A response is withheld | The terminal audit write failed | Restore the audit sink and verify its integrity before accepting traffic. | diff --git a/docs/site/src/content/docs/tutorials/publish-governed-sqlite-registry.mdx b/docs/site/src/content/docs/tutorials/publish-governed-sqlite-registry.mdx index a6e6a3eec..80e50d650 100644 --- a/docs/site/src/content/docs/tutorials/publish-governed-sqlite-registry.mdx +++ b/docs/site/src/content/docs/tutorials/publish-governed-sqlite-registry.mdx @@ -117,7 +117,8 @@ relayctl check "$project" --production ``` After the command label, the report begins with a successful status and no -diagnostics: +diagnostics. This abridged excerpt omits the compiled configuration key paths +at `details.configuration_key_paths`: ```json { @@ -125,8 +126,8 @@ diagnostics: "diagnostics": [], "details": { "kind": "check", - "production": true, - "contract_revision": "sha256:" + "contract_revision": "sha256:", + "production": true } } ``` @@ -239,8 +240,9 @@ refusal case in the full journey and cannot change the compiled query. The contract also defines a protected `registrar` representation for the same read and list operations. The full fixture journey proves its distinct scope, no-store cache posture, a denied -request, and an unknown representation. Relay authorizes the requested representation exactly and -never falls back to the public default. +request, and an unknown representation. The unknown and scope-hidden cases use the same generic +`404 resource.not_found` response. Relay authorizes the requested representation exactly and never +falls back to the public default. Starting the packaged service requires the configured token issuer to be reachable because the package contains protected representations, even when the request you plan to send is public. diff --git a/products/relay-v2/CONCEPT.md b/products/relay-v2/CONCEPT.md index 99c0e5d29..3893fbd91 100644 --- a/products/relay-v2/CONCEPT.md +++ b/products/relay-v2/CONCEPT.md @@ -108,9 +108,9 @@ Every returned Record carries a non-selectable core context: - `domainData`, containing only the authorized and requested domain properties. The pair `(registryIdentifier, recordIdentifier)` identifies a Record. The -record identifier is stable and cannot be reassigned. A JSON-LD `@id` may be -derived as a global IRI, but it never replaces or changes the authoritative -record identifier. +record identifier is stable and cannot be reassigned. JSON-LD adds a derived +global `@id` and the resource semantic class as `@type`, but neither replaces +or changes the authoritative record identifier. Record identifier, record revision, lifecycle state, and recorded time are explicitly bound from the reviewed source view. Registry identity, authority, @@ -297,6 +297,10 @@ exact equality. Any non-empty subset of declared filters is valid; the contract separately declares whether an unfiltered request is allowed. `pageSize`, `cursor`, `fields`, and `representation` are reserved names. Filters in query strings are limited to non-personal selectors. Relay binds their values as SQL parameters. +Transforms are response-only: a transformed property cannot be a filter or +fixed-order key because doing so would compare or order its undisclosed raw +input. A Registry that needs that query shape exposes a separately reviewed +pre-derived source property. Callers cannot introduce source columns, joins, operators, expressions, arbitrary sorting, or SQL. @@ -321,7 +325,9 @@ map of reviewed representations, exactly one `defaultRepresentation`, and one access rule plus one disclosure profile per representation. An absent `representation` selects that sole declared default. A supplied representation is authorized exactly as requested: denial, an invalid bearer, or an unknown -identifier never falls back to another profile. Within the selected profile, +identifier never falls back to another profile. A syntactically valid unknown +name and a scope-hidden name receive the same `resource.not_found` response, so +callers cannot enumerate the finite map. Within the selected profile, requester-selected `fields` can only disclose less and never lower the operation's compiled handling level, authentication, audit, quota, metadata, or cache posture. Caller-dependent or tag-derived profiles remain out of @@ -402,7 +408,10 @@ required, while a selectable `domainData` property is validated when present. A separate operator validation schema and SHACL shape describe the complete source Record and preserve source requiredness. `semanticModelReference` points to the generated local vocabulary/model, not merely the JSON-LD context. The -context is linked separately in `meta`. Compilation fails unless every audience +context is linked separately in `meta`. The context expands response `data` and +`items` as RDF graph containers, nests `domainData` properties without a +transport predicate, and applies the same IRI and datatype constraints emitted +in the bound SHACL shape. Compilation fails unless every audience that can receive a Record can also retrieve safe projections of the exact schema and semantic model referenced by that Record. @@ -415,7 +424,8 @@ is never propagated because Relay cannot establish that vendor state is value-free. Unknown, hidden, and ambiguous lookup outcomes use the same `404` status, problem code, fixed detail, schema, cache and security headers, differing only in independently generated trace correlation. A selected -malformed source Record fails closed as `503 source.unavailable`. Problems +malformed source Record, including invalid input to a compiled transform, +fails closed atomically as `503 source.unavailable`. Problems never echo selectors, identifiers, source values, SQL, paths, tokens, or policy internals. @@ -452,14 +462,14 @@ disclosure profile as described above. The initial access model combines: - a strictly verified OAuth 2.0 JWT access token when the operation is protected; -- one explicit operation scope; +- one explicit access rule for each finite representation, with an exact scope when protected; - optional trusted purpose; - optional authority-to-row binding from the resolved principal or a verified claim; - the selected disclosure profile. Purpose comes from, or is constrained by, verified authority. A caller header never creates authority. Principal binding is a compiler-declared equality boundary injected by Relay and cannot be replaced by caller filters. -The resource posture and contract define the maximum compiled operation set. Token scopes can only narrow it. Separate scopes for list, read, and named lookup allow an issuer to give a client exact-lookup access without collection or identifier-read access. Conversely, no token can enable an operation the resource did not compile. Relay does not maintain a client registry; the trusted issuer registers clients and assigns scopes. +The resource posture and contract define the maximum compiled operation set. Token scopes can only narrow it. Separate scopes for list, read, named lookup, and their finite representations allow an issuer to give a client exact-lookup access without collection or identifier-read access. A valid principal without the selected scope receives the same concealed `resource.not_found` outcome as an unknown operation. Conversely, no token can enable an operation or representation the resource did not compile. Relay does not maintain a client registry; the trusted issuer registers clients and assigns scopes. Each request produces a typed access decision followed by a typed disclosure plan. The plan contains the authorized operation and representation, row constraints, selected disclosure profile, and any requester-selected property subset. This architectural seam keeps authentication, authorization, row constraints, query construction, and serialization separate. Version one uses static reviewed disclosure plans and does not require a general PDP, CEL, dynamic masking, per-client field permissions, or tag-based ABAC. @@ -518,10 +528,10 @@ silently, or hot-reloads a partially valid contract. Relay supports two explicit SQLite profiles: -- snapshot: read-only immutable access, stable file identity and digest, no uncheckpointed sidecars, and exact source revision; +- snapshot: read-only immutable access, stable file identity and digest, no uncheckpointed sidecars, exact source revision, and digest verification before and after every statement execution; - live read-only: another trusted process may update the database, while Relay keeps a fixed registry contract, uses a consistent read transaction per request, and verifies schema fingerprints when the SQLite schema changes. -Snapshot mode provides stronger reproducibility and provenance, but is optional. Live mode never claims the exact historical reproducibility of a captured snapshot. In both modes the Relay process has read-only operating-system and SQLite access, even though a trusted publisher may hold separate write access to a live database. +Snapshot mode provides stronger reproducibility and provenance, but is optional. The deployment must make the captured file immutable outside Relay, preferably through a read-only mount; per-execution digest checks detect drift but cannot exclude a privileged writer that changes and restores bytes entirely between both checks. Live mode never claims the exact historical reproducibility of a captured snapshot. In both modes the Relay process has read-only operating-system and SQLite access, even though a trusted publisher may hold separate write access to a live database. Snapshot responses identify the captured source digest and may support lists, cursors, and validators. Version one live sources are deliberately unversioned: @@ -534,10 +544,11 @@ pagination, and live caching are deferred until a real registry requires them. Every data request, including anonymous public access, durably records either a refusal before returning or a pre-source attempt followed by a terminal -release or unresolved outcome. Audit is a source-access and response-release +release, unresolved, or source-failed outcome. Audit is a source-access and response-release gate. An event identifies the Registry, resource, operation, access-rule -revision, purpose when present, applied row-boundary kind, disclosure profile, -selected property identifiers or their digest, effective handling levels, +revision, purpose when present, applied row-boundary kind, representation, +disclosure profile, selected property identifiers or their digest, transform +identifiers, effective handling levels, contract revision, and snapshot or live-source revision. Anonymous public events use an explicit anonymous principal kind and no synthetic person identifier. Audit contains no tokens, selector values, source values, response @@ -698,7 +709,7 @@ The first coherent Relay V2 release should contain: 5. generated local semantics, JSON-LD, JSON Schema, SHACL, full packaged OpenAPI, and safe public OpenAPI; 6. property classification with provenance and the `public`, `internal`, `confidential`, and `restricted` handling levels; 7. derived public, protected, or absent enumeration with independently compiled list, read, and named-lookup operations; -8. `pageSize` and client-opaque integrity-protected cursor lists, direct predefined equality filters, and safe caller selection of fewer properties than the operation profile; +8. `pageSize` and client-opaque authenticated-encrypted cursor lists, direct predefined equality filters, and safe caller selection of fewer properties than the selected representation; 9. strict OAuth JWT access-token verification, operation scopes, trusted purpose, optional authority row binding, and an optional conforming Mint issuer; 10. snapshot and live read-only SQLite profiles, including useful unversioned live read and lookup deployments; 11. deterministic disclosure plans, bounded queries, stable `404` lookup outcomes, atomic activation, and Registry Stack problems; diff --git a/products/relay-v2/DEFINITION-OF-DONE.md b/products/relay-v2/DEFINITION-OF-DONE.md index 73e4344cf..6af4ae543 100644 --- a/products/relay-v2/DEFINITION-OF-DONE.md +++ b/products/relay-v2/DEFINITION-OF-DONE.md @@ -41,26 +41,26 @@ prove in-process resource isolation without adding a fourth deployment project. | Registry identity | One contract and process describes exactly one Registry with a globally stable `registryIdentifier`, name, Registry Authority, optional operator, authoritative scope, base URI, and pinned alignment targets. Resources are Record types within that Registry. Authority, controller, publisher, and operator remain distinct roles even when one institution fills several. | | Registry Core | Every returned Record contains non-selectable `registryIdentifier`, `recordIdentifier`, `revisionIdentifier`, `lifecycleState`, `schemaReference`, `semanticModelReference`, `authorityIdentifier`, `recordedAt`, and selected `domainData`. Record identifier, revision, lifecycle, and recorded time are source-bound; recorded time is never Relay observation time. The Registry and Record identifier pair is stable, and the contract names the institution's identifier-lifecycle policy without claiming Relay can prove non-reassignment from one current database. | | Family capabilities | Each compiled operation carries a derived family and pattern: read is `consultation.retrieve`, list is `consultation.list`, and named exact lookup is constrained `consultation.search`. Capability discovery is generated from operations rather than separately authored. Relay makes no claim for Record Match or the Provisioning, Evidence, Write, Notification, Aggregate Data, Access Transparency, or Identity Federation families. | -| Compilation and activation | The shared compiler validates the complete contract before packaging, produces one deterministic compiled Registry and contract revision, and seals them with every artifact digest. Before listening, Relay verifies the closed package and recompiles the captured contract, observed schemas, and governed files solely to prove that the packaged runtime plan is identical. It does not regenerate artifacts. Incomplete semantics, unclassified published properties, invalid source bindings, schema drift, conflicting operations, unsafe access rules, or package inconsistency prevent packaging or readiness. There is no partial activation, runtime merge, silent fallback, or hot reload. | +| Compilation and activation | The shared compiler validates the complete contract before packaging, produces one deterministic compiled Registry and contract revision, and seals them with every artifact digest. Before listening, Relay verifies the closed package, recompiles the captured contract, observed schemas, and governed files to prove that the packaged runtime plan is identical, and deterministically rederives every generated artifact solely to exact-compare it with the packaged artifact set. Activation uses the verified packaged bytes. Incomplete semantics, unclassified published properties, invalid source bindings, schema drift, conflicting operations, unsafe access rules, or package inconsistency prevent packaging or readiness. There is no partial activation, runtime merge, silent fallback, or hot reload. | | Domain neutrality | Production Rust, public configuration schemas, routes, CLI options, and generated generic contracts contain no social-registry, business-registry, CRVS, birth, death, household, benefit, company, or acceptance-fixture-specific type, branch, feature, or operation. Such terms appear only in examples, fixtures, and explanatory documentation. | | SQLite source boundary | Snapshot and live read-only profiles use one hardened SQLite boundary with OS and engine read-only enforcement, defensive authorizer rules, bound values, consistent per-request transactions, schema fingerprints, and step, time, row, cell, response, queue, and concurrency limits. Writes, schema changes, control statements, attachment, extension loading, and unreviewed SQL are impossible through the public contract. | -| Snapshot profile | A deployment-bound snapshot is captured outside the contract package, bound to its exact file identity and digest, refuses unsafe sidecars or path replacement, opens immutably, and reports a truthful source revision. Identical governed package and snapshot inputs produce identical revisions and generated artifacts. Snapshot mode is supported but not required for a deployment. | +| Snapshot profile | A deployment-bound snapshot is captured outside the contract package, bound to its exact file identity and digest, refuses unsafe sidecars or path replacement, opens immutably, verifies exact bytes before and after each statement execution, and releases no rows under a stale source revision. The deployment keeps the file externally immutable, preferably on a read-only mount. Identical governed package and snapshot inputs produce identical revisions and generated artifacts. Snapshot mode is supported but not required for a deployment. | | Live profile | A live database is opened read-only while a separately trusted publisher may update it. Each response uses one consistent read transaction and verifies the expected schema fingerprint. Version one live sources compile read and exact lookup only, always return `sourceRevision: {profile: live, status: unversioned, value: null}`, use `no-store`, and emit no ETag. Publisher-owned revisions, live pagination, and live caching are deferred. | -| Closed operation model | Resources compile only declared list, identifier-read, and named exact-lookup operations. A list's operation-owned query shape determines whether enumeration is permitted; absence of list means no enumeration. Collection filters are direct publisher-defined camelCase query parameters, typed, non-personal, and exact-equality only. Any non-empty subset of declared filters is valid, and the contract separately permits or forbids unfiltered access. `pageSize`, `cursor`, `fields`, and `representation` are reserved. Lookups have complete bounded body inputs and exactly zero or one disclosed result. Callers cannot add SQL, source columns, joins, expressions, operators, paths, projection expressions, sort orders, or page traversal. | -| Representation selection and requester minimization | Every operation has a finite ordered `representations` map and exactly one explicit `defaultRepresentation`. If any representation is public, the default must be public so omission is truthful in public OpenAPI and anonymous clients never select a hidden protected default. The direct `representation` parameter accepts exactly one non-empty compiled identifier; absence selects the default. Relay authenticates a supplied bearer before selection and authorizes only the selected representation. Malformed, repeated, or empty selection is `400 request.representation_invalid`; an unavailable selection is concealed from a principal without its operation scope; purpose or row-binding denial after scope selection is `403 consultation.denied`. No request falls back to another representation or reaches source access after denial. `fields` may select only a non-empty duplicate-free subset of that selected profile. Registry Core remains present. Unknown, internal, source-column, cross-profile, or malformed selections fail before source access and cannot change predicates, bindings, validation, authorization, effective handling, audit, quota, metadata, or cache posture. | +| Closed operation model | Resources compile only declared list, identifier-read, and named exact-lookup operations. A list's operation-owned query shape determines whether enumeration is permitted; absence of list means no enumeration. Collection filters are direct publisher-defined camelCase query parameters, typed, non-personal, and exact-equality only. Transformed properties cannot be filters or fixed-order keys; queryable derived values must be reviewed pre-derived source properties. Any non-empty subset of declared filters is valid, and the contract separately permits or forbids unfiltered access. `pageSize`, `cursor`, `fields`, and `representation` are reserved. Lookups have complete bounded body inputs and exactly zero or one disclosed result. Callers cannot add SQL, source columns, joins, expressions, operators, paths, projection expressions, sort orders, or page traversal. | +| Representation selection and requester minimization | Every operation has a finite ordered `representations` map and exactly one explicit `defaultRepresentation`. If any representation is public, the default must be public so omission is truthful in public OpenAPI and anonymous clients never select a hidden protected default. The direct `representation` parameter accepts exactly one non-empty compiled identifier; absence selects the default. Relay authenticates a supplied bearer before selection and authorizes only the selected representation. Malformed, repeated, or empty selection is `400 request.representation_invalid`; a syntactically valid unknown name, an anonymous explicit request for a protected name, and a valid principal without the selected representation scope receive the same concealed `404 resource.not_found` outcome; purpose or row-binding denial after scope selection is `403 consultation.denied`. No request falls back to another representation or reaches source access after refusal. `fields` may select only a non-empty duplicate-free subset of that selected profile. Registry Core remains present. Unknown, internal, source-column, cross-profile, or malformed field selections fail before source access and cannot change predicates, bindings, transforms, validation, authorization, effective handling, audit, quota, metadata, or cache posture. | | HTTP and pagination | Business routes are under `/v2`; `/health`, `/ready`, and `/openapi.json` are unversioned. `GET /v2` publishes safe first-class Registry service metadata and visible derived capabilities. Lists accept bounded `pageSize` and a client-opaque, authenticated-encrypted `cursor` and return `{items, pageInfo: {nextCursor}, meta}` with nullable `nextCursor`. Cursor confidentiality prevents filters and keyset-order values from bypassing field minimization; integrity binds revisions, operation, selected representation and disclosure profile, filters, fixed order, field set, authorization context, and expiry; each page is reauthorized. Single reads and resolved lookups return `{data, meta}`. No caller sorting exists. | -| Query and serialization minimization | Relay may read the complete fixed reviewed projection so it can validate the authoritative Record before disclosure. Unrequested and hidden columns are never serialized. Required null, wrong type, noncanonical value, or size failure releases nothing and returns value-free `503 source.unavailable` for read, list, and lookup. Ordinary JSON and JSON-LD disclose the same Registry Core identity and selected domain values with deterministic property order. JSON-LD adds the generated context and a derived `@id` without replacing `recordIdentifier`. Cacheable responses require a public selected representation, public processing handling, and a snapshot; their strong ETag binds exact selected-profile bytes, `Vary: Accept, Authorization`, `If-None-Match`, and `304`. Other responses are `no-store` and have no ETag. | +| Query and serialization minimization | Relay may read the complete fixed reviewed projection so it can validate the authoritative Record before disclosure. Unrequested and hidden columns are never serialized. Required null, wrong type, noncanonical value, transform-input failure, or size failure releases nothing and returns value-free `503 source.unavailable` for read, list, and lookup. Ordinary JSON and JSON-LD disclose the same Registry Core identity and selected domain values with deterministic property order. JSON-LD adds the generated context, a derived `@id`, and the resource semantic class as `@type` without replacing `recordIdentifier`. Cacheable responses require a public selected representation, public processing handling, and a snapshot; their strong ETag binds exact selected-profile bytes, `Vary: Accept, Authorization`, `If-None-Match`, and `304`. Other responses are `no-store` and have no ETag. | | Semantic contract | Every resource and property has a stable local semantic identity, datatype, cardinality, label, and description. `relayctl` can generate reviewed starter semantics, JSON-LD context, permitted-representation JSON Schema and SHACL, full-record validation schema and SHACL, and codelist scaffolding without requiring prior semantic-web expertise. The representation artifacts require Registry Core and validate selectable domain properties only when present; full-record artifacts retain source requiredness. `semanticModelReference` resolves to the generated vocabulary/model and the context is linked separately. Generated suggestions are visibly non-authoritative until accepted. | | External semantic alignment | Optional mappings to SEMIC, PublicSchema, schema.org, or another profile are curated, relation-qualified, versioned, and digest-pinned. Relay fetches no vocabulary and performs no inference at request time. Mapping changes appear in change-impact reports and cannot silently widen disclosure. | | Identification, classification, and review | The existing axes remain exact: `semanticTerm`, `privacy`, `institutional`, `handling`, `status`, and `provenanceRef`. `relayctl` identification is offline, schema-only, deterministic, explainable, and value-free; it reads no source values and no candidate self-approves. Every property and every processed source-view column has an effective reviewed classification. `sourceColumnClassifications` is explicit and complete for every multiply-bound column. The reviewed `ClassificationReview` at `classifications.provenanceRef` digest-binds Registry identity and the classification inventory; generated review additionally binds its accepted report and exact rule pack. Missing, suggested, uncertain, stale, or tampered review fails production compilation. Manual and imported review remain first-class. | -| Processing versus disclosure handling | Resource defaults reduce repetition; compilation expands defaults and explicit overrides before validation. Processing handling is the maximum across Registry Core, direct output, selector, filter, order, and row-binding source columns. Disclosure handling is the maximum across serializable properties for the selected representation. Authentication, audit, cache, source controls, and public eligibility use processing handling. A public representation may not process a non-public raw column; publishers create a reviewed pre-derived public view column when that is appropriate. Handling is one of ordered `public`, `internal`, `confidential`, or `restricted`; non-public processing requires authentication, scope, `no-store`, and durable value-free audit, and restricted data cannot be listed. Purpose and row binding remain explicit access constraints. | -| Authentication and issuers | Relay acts as an OAuth 2.0 JWT resource server for protected operations and Version one configures exactly one issuer per Registry deployment. Relay strictly verifies issuer, audience, token type, algorithm, key, time, client or subject, token identifier, and scope claims. Missing or invalid credentials return safe registry-wide `401` responses. A valid principal lacking the operation scope receives the same `404 resource.not_found` as an unknown resource or operation; after the scope selects the operation, insufficient purpose or authority returns `403 consultation.denied`. Anonymous access exists only on operations explicitly compiled as public. | +| Processing versus disclosure handling | Resource defaults reduce repetition; compilation expands defaults and explicit overrides before validation. Processing handling is the maximum across Registry Core, direct output, transform input, selector, filter, order, and row-binding source columns. Disclosure handling is the maximum across serializable properties for the selected representation. Authentication, audit, cache, source controls, and public eligibility use processing handling. A public representation may not transform a non-public raw column; publishers create a reviewed pre-derived public view column when that is appropriate. Handling is one of ordered `public`, `internal`, `confidential`, or `restricted`; non-public processing requires authentication, scope, `no-store`, and durable value-free audit, and restricted data cannot be listed. Purpose and row binding remain explicit access constraints. | +| Authentication and issuers | Relay acts as an OAuth 2.0 JWT resource server for protected operations and Version one configures exactly one issuer per Registry deployment. Relay strictly verifies issuer, audience, token type, algorithm, key, time, client or subject, token identifier, and scope claims. Invalid credentials and omitted credentials on a protected default operation return safe registry-wide `401` responses. An anonymous explicit request for a protected representation and a valid principal lacking the selected operation or representation scope receive the same `404 resource.not_found` as an unknown resource or operation; after the scope selects the representation, insufficient purpose or authority returns `403 consultation.denied`. Anonymous access exists only on representations explicitly compiled as public. | | Operation authorization | List, read, and named lookup use distinct registered scopes that are unique across the Registry contract. Trusted purpose and authority-to-row binding are optional compiled constraints and can come only from the resolved principal or a direct verified scalar claim. Caller filters or headers never create authority. A lookup-only client cannot enumerate or perform identifier reads, even when another client can use those operations on the same deployment. | | Optional Mint pairing | Relay accepts a conforming token from an external authorization server without Mint. A Mint deployment may be paired when it emits the same Relay audience, operation scopes, and optional authority claims from server-side grants. Relay has no Mint runtime dependency or Mint-specific authentication branch. Mint changes and a Mint integration journey do not block the core Relay V1 acceptance path. | | Lookup containment | Sensitive selectors use a bounded request body, are bound rather than rendered, and never appear in URLs, errors, logs, metrics, traces, audit, or responses. No match, ambiguity, policy-hidden record, and unknown or protected identifier share one `404` outcome with the same Registry Stack problem type, code, detail, schema, and headers. Only independently generated trace correlation may differ. An invalid selected source row is a value-free `503 source.unavailable`; invalid request syntax is a value-free bounded request error. Rate and concurrency limits make consultation abuse observable and bounded. | -| Validation and failure | Every selected row is schema-validated before release. Relay never skips, coerces, truncates, or partially releases an invalid row to preserve success. Errors use the fixed Registry Stack status/code catalog and derived type URIs plus `traceId`, exactly the 32-lowercase-hex trace ID of the effective valid or server-generated W3C Trace Context. Caller-supplied `tracestate` is never propagated. Problems contain no field-error array, SQL, paths, schema internals, selector values, source values, token material, or subject identifiers. Draft GovStack error namespaces are not used. Required audit or other release-gate failure prevents disclosure. | +| Validation and failure | Every selected row and transform input is validated before release. Relay never skips, coerces, truncates, or partially releases an invalid row to preserve success. Errors use the fixed Registry Stack status/code catalog and derived type URIs plus `traceId`, exactly the 32-lowercase-hex trace ID of the effective valid or server-generated W3C Trace Context. Caller-supplied `tracestate` is never propagated. Problems contain no field-error array, SQL, paths, schema internals, selector values, source values, token material, or subject identifiers. Draft GovStack error namespaces are not used. Required audit or other release-gate failure prevents disclosure. | | Unsigned response boundary | Relay responses are not signed. TLS and access-token verification protect the live exchange, while revisions, ETags, provenance, and tamper-evident audit support accountability without being described as signatures. Evidence can consume a fixed Relay lookup when a portable signed minimum-disclosure assertion is required. | -| Audit and provenance | Every public or protected data request processed by Relay durably records either a refusal before returning or a pre-source attempt followed by one terminal release or unresolved outcome. Durable audit gates source access and response release. Events carry stable identifiers for Registry, resource, operation, access-rule revision, optional purpose, row-boundary kind, representation, disclosure profile, selected-property set or digest, processing handling, disclosure handling, contract revision, and truthful source revision. Terminal audit covers exact held selected-profile bytes before release. Anonymous calls record an anonymous principal kind. Audit contains no tokens, selector values, source values, response values, SQL, or raw subject identifiers. The safeguards report names public shared-cache hits as outside Relay observation. | +| Audit and provenance | Every public or protected data request processed by Relay durably records either a refusal before returning or a pre-source attempt followed by one terminal release, unresolved, or source-failed outcome. Durable audit gates source access and response release. Events carry stable identifiers for Registry, resource, operation, access-rule revision, optional purpose, row-boundary kind, representation, disclosure profile, selected-property set or digest, processing handling, disclosure handling, transform identifiers, contract revision, and truthful source revision. Terminal audit covers exact held selected-profile bytes before release. Anonymous calls record an anonymous principal kind. Audit contains no tokens, selector values, source values, response values, SQL, or raw subject identifiers. The safeguards report names public shared-cache hits as outside Relay observation. | | Metadata visibility and per-profile artifacts | Registry service identity is public. Other resource, capability, OpenAPI, semantic, classification, processing, and operational metadata is `public`, `operation-bound` behind the same static gate as the operation and representation whose Record links it, or `operator-only` in package/CLI with no HTTP route. Public metadata never inventories a protected representation identifier, profile schema, SHACL shape, JSON-LD context, semantic model, classification, processing description, or OpenAPI path. The package contains every profile's artifacts and full OpenAPI; `/openapi.json` is a deterministic safe public projection. Compilation fails if any successful Record audience cannot resolve a safe operation-bound projection of its exact profile schema and semantic model. | | Freshness and caching | Snapshot and live responses expose truthful, profile-specific revision and cache behavior. Every public snapshot response uses `Cache-Control: public, no-cache`, a strong exact-byte ETag, and revalidation. Every non-public or live response is `no-store`. `Vary: Authorization` prevents an anonymous cached `200` from serving a request with an invalid bearer. No response implies a stable cross-request snapshot, and field subsets cannot collide. | | Generated contracts | OpenAPI 3.1, JSON Schema, SHACL, JSON-LD contexts, codelists, and capability discovery are generated reproducibly from the compiled contract. Full and public OpenAPI projections have drift checks. No artifact is generated from the obsolete Digital Registries OpenAPI. | @@ -90,7 +90,7 @@ For each of the three coequal registries: 2. Registry identity, authority, scope, alignment targets, and derived Consultation capabilities are correct; 3. offline fixtures and the real HTTP service return the same semantic result; 4. every returned Record has valid Registry Core context, and `recordedAt` and `revisionIdentifier` come from the source view; -5. ordinary JSON and JSON-LD are data-equivalent and validate against generated contracts; +5. ordinary JSON and JSON-LD are data-equivalent after removing the JSON-LD `@id` and `@type`; every returned Record validates against the exact generated permitted-representation JSON Schema, its operation/representation binding resolves the corresponding generated SHACL artifact, and the actual JSON-LD response expands to an RDF graph whose class, IRI nodes, predicates, and datatypes match that SHACL shape and the compiled model; 6. default and explicitly requested representations, plus at least two valid `domainData` subsets within a selected representation, succeed while Registry Core remains complete; 7. an unknown property, source-column name, cross-profile property, duplicate property, malformed selection, malformed/repeated representation, unknown representation, and denied selected representation fail without source or value leakage or fallback; 8. invalid selected source rows fail the whole response closed with value-free `503 source.unavailable`; every Registry proves at least one such refusal, and the coequal suite covers wrong type, missing required value, extra unexpected value, and excessive size; @@ -140,7 +140,7 @@ value-free operational log dimensions. - registrar and supervisory representations are selected explicitly, are independently scoped, and never fall back; neither read nor lookup scope can synthesize the other; - the civil journey proves `date-precision` (`year` and `year-month`) with distinct output terms/types; focused transform and real-router tests reject null, noncanonical, incompatible, and oversized source values with value-free source failure; - the external-issuer path is complete; a later optional Mint pairing must traverse the same verifier and access-decision path; -- no match, ambiguity, jurisdiction-hidden row, invalid event record, wrong purpose, and wrong jurisdiction binding collapse according to the lookup contract; +- no match, ambiguity, and a jurisdiction-hidden row collapse to the unresolved lookup outcome; an invalid event record or transform input fails as value-free `503 source.unavailable`, while wrong purpose and wrong jurisdiction binding retain their distinct governed refusal behavior; - the fixed Relay lookup remains an ordinary protected HTTP source contract suitable for a future Evidence integration, without adding signing behavior to Relay; a real Evidence pairing is a separate non-blocking journey. diff --git a/products/relay-v2/IMPLEMENTATION.md b/products/relay-v2/IMPLEMENTATION.md index 2ce958177..de3eb1915 100644 --- a/products/relay-v2/IMPLEMENTATION.md +++ b/products/relay-v2/IMPLEMENTATION.md @@ -148,7 +148,10 @@ Relay service user and must not be writable by another account; only a root-owned sticky directory is accepted as a shared ancestor. Production packages exclude fixtures and SQLite data. Snapshot and live database paths are deployment bindings; Relay captures a snapshot digest or explicitly reports an -unversioned live source. `relay serve --runtime ` resolves the sealed +unversioned live source. Snapshot execution verifies the captured digest before +and after every statement; operators still provide external immutability, +preferably a read-only mount, because no process can exclude a privileged +change-and-restore entirely between those checks. `relay serve --runtime ` resolves the sealed package only from the runtime's `packagePath`; it never accepts a mutable authoring project or loose contract file. @@ -237,7 +240,8 @@ Every successful Record has: ``` The Registry and Record identifier pair is authoritative. JSON-LD adds a -derived global `@id` but retains both identifiers. Lifecycle values come from +derived global `@id` and the resource semantic class as `@type` but retains +both identifiers. Lifecycle values come from the resource's governed codelist. `recordedAt` is source-owned and is never Relay observation time. @@ -313,11 +317,13 @@ wrong scalar types, coercion, normalization, and extra nesting are rejected. `fields` remains in the query string and never appears in the body. For `application/ld+json`, each response carries `@context` and each Record adds -a derived `@id`. The generated context maps -`domainData` to JSON-LD `@nest`, maps its property keys to their semantic IRIs, -types Registry Core IRI members as `@id`, and maps transport-only `meta` and -`pageInfo` to null so they do not become domain triples. Ordinary JSON retains -the shapes above without `@context` or `@id`. +a derived `@id` plus its resource semantic class as `@type`. The generated +context maps response `data` and `items` to JSON-LD `@graph`, aliases +`domainData` to `@nest`, maps its property keys to their semantic IRIs, coerces +Registry Core and domain values to the same IRI or XML Schema datatypes used by +the bound SHACL shape, and maps transport-only `meta` and `pageInfo` to null so +they do not become domain triples. Ordinary JSON retains the shapes above +without `@context`, `@id`, or `@type`. ### HTTP binding and capabilities @@ -385,10 +391,14 @@ List filters are direct declared camelCase query parameters, exact-equality only, non-personal, unique, and cannot be named `pageSize`, `cursor`, or `fields`. Any non-empty subset of declared filters is valid. The operation explicitly declares whether the empty subset is allowed with `allowUnfiltered`. +Transformed properties are response-only and fail compilation when named as a +filter or fixed-order key. A queryable derived value must be a separately +reviewed pre-derived source property. `pageSize` is bounded by the operation default and maximum. Ordering is fixed with `recordIdentifier` as the unique tie-breaker. The client-opaque authenticated-encrypted cursor binds contract and source revisions, operation, -filters, order, fields, authorization-relevant context, and expiry. Every page +selected representation and disclosure profile, filters, fixed order, fields, +authorization-relevant context, and expiry. Every page is reauthorized. Authenticated encryption prevents filters and keyset-order values from bypassing field minimization. A caller cannot sort, name a source column, add an operator, or traverse an uncompiled page. @@ -481,10 +491,9 @@ error array is emitted. | Malformed, expired, stale, or differently bound cursor | 400 | `query.cursor_invalid` | `cursor is invalid for this query` | | Missing credential on a protected operation | 401 | `auth.missing_credential` | `a bearer access token is required` | | Invalid credential | 401 | `auth.invalid_credential` | `bearer access token validation failed` | -| Valid credential without the operation scope | 404 | `resource.not_found` | `the requested resource was not found` | +| Valid credential without the selected operation or representation scope | 404 | `resource.not_found` | `the requested resource was not found` | | Insufficient purpose or row authority after scope selection | 403 | `consultation.denied` | `the consultation is not permitted` | -| Unknown or visibility-hidden resource or artifact | 404 | `resource.not_found` | `the requested resource was not found` | -| Unknown or unavailable requested representation | 404 | `representation.not_found` | `the requested representation was not found` | +| Unknown or visibility-hidden resource, artifact, operation, or representation | 404 | `resource.not_found` | `the requested resource was not found` | | Unknown, hidden, ambiguous, or policy-hidden Record outcome | 404 | `consultation.unresolved` | `the requested record was not resolved` | | Unsupported response `Accept` | 406 | `representation.unsupported` | `the requested representation is not supported` | | Request body too large | 413 | `internal.payload_too_large` | `request body exceeds the configured limit` | @@ -492,7 +501,7 @@ error array is emitted. | Unsupported request body media type | 415 | `request.media_type_unsupported` | `request body must use application/json` | | Relay consultation quota exhausted | 429 | `consultation.rate_limited` | `the consultation quota is exhausted` | | Unhandled failure | 500 | `internal.unhandled` | `the request could not be served` | -| Source unavailable or schema drifted | 503 | `source.unavailable` | `the authoritative source is unavailable` | +| Source unavailable, schema drifted, invalid selected row, or invalid transform input | 503 | `source.unavailable` | `the authoritative source is unavailable` | | Durable audit unavailable | 503 | `audit.unavailable` | `required audit is unavailable` | | Service not ready or unhealthy | 503 | `service.not_ready` | `the service is not ready` | | Request deadline exceeded | 504 | `internal.timeout` | `request exceeded the configured timeout` | @@ -528,15 +537,18 @@ Protected operations accept only a registered JWT access-token profile: single-use claim; - principal resolved in order from `sub`, `client_id`, then `azp`, with a malformed higher-priority claim failing rather than falling through; -- one explicit operation scope plus any compiled purpose and row-binding claim. +- one exact selected-representation scope plus any compiled purpose and row-binding claim. The deployment has at most one issuer verification configuration. Its exact -issuer is checked before its keys can authorize the token. A missing -bearer is allowed only for an explicitly public operation; an invalid bearer is -never treated as anonymous. Caller purpose headers are rejected. Purpose and +issuer is checked before its keys can authorize the token. A missing bearer is +allowed only for an explicitly public default representation. An anonymous +explicit request for a protected representation is concealed like an unknown +representation; an invalid bearer is never treated as anonymous. Caller purpose +headers are rejected. Purpose and row authority come only from verified claims assigned by the authorization server. Every protected operation scope is non-empty and unique across the -Registry contract. An authority row binding explicitly selects either the +Registry contract. Missing selected-representation scope is concealed as +`resource.not_found` before source access. An authority row binding explicitly selects either the resolved principal or one direct verified scalar claim. Relay injects its hidden typed equality predicate; caller filters cannot satisfy or replace it. @@ -545,13 +557,14 @@ as a release gate: 1. append the attempt before source access; 2. append any refusal before returning it; -3. serialize and validate successful bytes; -4. append the release outcome before those bytes leave the process. +3. validate the selected row and transforms before serialization; +4. append a source-failed outcome before returning a source failure; +5. serialize successful bytes and append the release outcome before those exact bytes leave the process. Audit sink failure returns `503` and prevents source access or response release at the relevant gate. Events contain stable Registry, resource, operation, -access-rule, processing, disclosure, selected-property, handling, contract, and -truthful source revision identifiers. They contain no token, selector, SQL, +representation, access-rule, processing, disclosure, transform, +selected-property, handling, contract, and truthful source revision identifiers. They contain no token, selector, SQL, path, source or response value, or raw principal identifier. Registry Mint is optional. Relay production crates do not depend on Mint. A diff --git a/products/relay-v2/acceptance/business-registry/expected-http.yaml b/products/relay-v2/acceptance/business-registry/expected-http.yaml index dfbf1874a..1eaf46a6b 100644 --- a/products/relay-v2/acceptance/business-registry/expected-http.yaml +++ b/products/relay-v2/acceptance/business-registry/expected-http.yaml @@ -88,13 +88,13 @@ steps: method: GET path: /v2/resources/registered-business/records/BIZ-SYNTH-0001 query: {representation: registrar} - expect: {status: 403, code: consultation.denied} + expect: {status: 404, code: resource.not_found} - id: public-representation-unknown request: method: GET path: /v2/resources/registered-business/records/BIZ-SYNTH-0001 query: {representation: registrar-private} - expect: {status: 404, code: representation.not_found} + expect: {status: 404, code: resource.not_found} - id: identifier-read-jsonld request: method: GET diff --git a/products/relay-v2/acceptance/civil-event/expected-http.yaml b/products/relay-v2/acceptance/civil-event/expected-http.yaml index 898d744b6..2170cf7d7 100644 --- a/products/relay-v2/acceptance/civil-event/expected-http.yaml +++ b/products/relay-v2/acceptance/civil-event/expected-http.yaml @@ -81,12 +81,13 @@ steps: request: method: POST path: /v2/resources/civil-event/lookups/verify-registration - query: {representation: supervisory, fields: "eventType,registrationYear"} + query: {representation: supervisory, fields: "eventType,registrationYear,registrationYearMonth"} body: {registrationNumber: REG-SYNTH-000001, eventType: BIRTH} expect: status: 200 registryCoreRequired: true - domainDataKeys: [eventType, registrationYear] + domainDataKeys: [eventType, registrationYear, registrationYearMonth] + domainDataValues: {registrationYear: "2026", registrationYearMonth: "2026-04"} cache: no-store - id: supervisory-representation-denied authorizationFixture: civil-verifier-ex-a @@ -95,7 +96,7 @@ steps: path: /v2/resources/civil-event/lookups/verify-registration query: {representation: supervisory} body: {registrationNumber: REG-SYNTH-000001, eventType: BIRTH} - expect: {status: 403, code: consultation.denied} + expect: {status: 404, code: resource.not_found} - id: invalid-representation authorizationFixture: civil-verifier-ex-a request: @@ -103,7 +104,7 @@ steps: path: /v2/resources/civil-event/lookups/verify-registration query: {representation: invalid} body: {registrationNumber: REG-SYNTH-000001, eventType: BIRTH} - expect: {status: 404, code: representation.not_found} + expect: {status: 404, code: resource.not_found} - id: no-list authorizationFixture: civil-registrar-ex-a request: {method: GET, path: /v2/resources/civil-event/records} @@ -193,6 +194,14 @@ steps: path: /v2/resources/civil-event/lookups/verify-registration body: {registrationNumber: REG-SYNTH-INVALID1, eventType: BIRTH} expect: {status: 503, code: source.unavailable} + - id: invalid-transform-input + authorizationFixture: civil-supervisor-ex-a + request: + method: POST + path: /v2/resources/civil-event/lookups/verify-registration + query: {representation: supervisory} + body: {registrationNumber: REG-SYNTH-XFORM1, eventType: BIRTH} + expect: {status: 503, code: source.unavailable} - id: quota-exhausted authorizationFixture: civil-verifier-ex-a request: diff --git a/products/relay-v2/acceptance/civil-event/governance/classification-review-rationale.md b/products/relay-v2/acceptance/civil-event/governance/classification-review-rationale.md index 603b96863..5e8082f71 100644 --- a/products/relay-v2/acceptance/civil-event/governance/classification-review-rationale.md +++ b/products/relay-v2/acceptance/civil-event/governance/classification-review-rationale.md @@ -1,4 +1,5 @@ # Classification review rationale -The supervisory year-precision property is a distinct reviewed output. Exact +The supervisory year-precision and year-month-precision properties are +distinct reviewed outputs with separate public types and semantic terms. Exact lookup remains protected and no collection representation is authorized. diff --git a/products/relay-v2/acceptance/civil-event/governance/classification-review.yaml b/products/relay-v2/acceptance/civil-event/governance/classification-review.yaml index efa2d5199..e88663510 100644 --- a/products/relay-v2/acceptance/civil-event/governance/classification-review.yaml +++ b/products/relay-v2/acceptance/civil-event/governance/classification-review.yaml @@ -1,7 +1,7 @@ apiVersion: relay.registrystack.org/classification-review/v1 kind: ClassificationReview registryIdentifier: urn:example:registry:civil-events -classificationInventoryDigest: sha256:e5a24dd38da75c806b8e0f5ecf5fd043f5e5661315fab94ba072c40d13bdc32b +classificationInventoryDigest: sha256:3da693b473e4989c6993fdd80ab9d312d650b6358335d20a32ea0de0836c10b7 method: manual reviewer: urn:example:institution:civil-registration-authority reviewDate: 2026-08-10 diff --git a/products/relay-v2/acceptance/civil-event/registry.yaml b/products/relay-v2/acceptance/civil-event/registry.yaml index 2dc13283b..c7807d517 100644 --- a/products/relay-v2/acceptance/civil-event/registry.yaml +++ b/products/relay-v2/acceptance/civil-event/registry.yaml @@ -105,6 +105,15 @@ resources: label: Registration year description: Reviewed year-precision form of the civil-event registration date. classification: {privacy: derived, institutional: confidential, handling: confidential, status: reviewed} + registrationYearMonth: + sourceColumn: registration_date + transform: {kind: date-precision, sourceType: date, precision: year-month} + type: year-month + sourceRequired: true + semanticTerm: local:registrationYearMonth + label: Registration year and month + description: Reviewed year-month-precision form of the civil-event registration date. + classification: {privacy: derived, institutional: confidential, handling: confidential, status: reviewed} registrationArea: sourceColumn: registration_area_code type: controlled-code @@ -127,7 +136,7 @@ resources: verification-result: properties: [eventReference, eventType, registrationStatus, registrationDate, certificateAvailable] supervisory-verification: - properties: [eventReference, eventType, registrationStatus, registrationYear, certificateAvailable] + properties: [eventReference, eventType, registrationStatus, registrationYear, registrationYearMonth, certificateAvailable] operations: read: defaultRepresentation: registrar diff --git a/products/relay-v2/acceptance/civil-event/semantics/local-vocabulary.yaml b/products/relay-v2/acceptance/civil-event/semantics/local-vocabulary.yaml index 56c46f6cf..0706b74fe 100644 --- a/products/relay-v2/acceptance/civil-event/semantics/local-vocabulary.yaml +++ b/products/relay-v2/acceptance/civil-event/semantics/local-vocabulary.yaml @@ -9,5 +9,7 @@ properties: - {id: eventType, label: Event type, datatype: controlled-code, cardinality: one} - {id: registrationStatus, label: Registration status, datatype: controlled-code, cardinality: one} - {id: registrationDate, label: Registration date, datatype: date, cardinality: one} + - {id: registrationYear, label: Registration year, datatype: year, cardinality: one} + - {id: registrationYearMonth, label: Registration year and month, datatype: year-month, cardinality: one} - {id: registrationArea, label: Registration area, datatype: controlled-code, cardinality: one} - {id: certificateAvailable, label: Certificate available, datatype: boolean, cardinality: one} diff --git a/products/relay-v2/acceptance/social-assistance/expected-http.yaml b/products/relay-v2/acceptance/social-assistance/expected-http.yaml index dbf3af727..ac490634d 100644 --- a/products/relay-v2/acceptance/social-assistance/expected-http.yaml +++ b/products/relay-v2/acceptance/social-assistance/expected-http.yaml @@ -55,6 +55,7 @@ steps: status: 200 registryCoreRequired: true domainDataKeys: [maskedEnrolmentReference, enrolmentStatus, validThrough] + domainDataValues: {maskedEnrolmentReference: "***0001"} - id: caseworker-representation authorizationFixture: social-caseworker-area-a request: @@ -73,7 +74,7 @@ steps: path: /v2/resources/assistance-enrolment/lookups/by-case-and-person query: {representation: caseworker} body: {caseReference: CASE-SYNTH-0001, personReference: PERSON-SYNTH-0001} - expect: {status: 403, code: consultation.denied} + expect: {status: 404, code: resource.not_found} - id: unknown-representation authorizationFixture: social-lookup-area-a request: @@ -81,7 +82,7 @@ steps: path: /v2/resources/assistance-enrolment/lookups/by-case-and-person query: {representation: unknown-profile} body: {caseReference: CASE-SYNTH-0001, personReference: PERSON-SYNTH-0001} - expect: {status: 404, code: representation.not_found} + expect: {status: 404, code: resource.not_found} - id: duplicate-representation authorizationFixture: social-lookup-area-a request: @@ -210,6 +211,13 @@ steps: path: /v2/resources/assistance-enrolment/lookups/by-case-and-person body: {caseReference: CASE-SYNTH-BAD2, personReference: PERSON-SYNTH-BAD2} expect: {status: 503, code: source.unavailable} + - id: invalid-transform-input + authorizationFixture: social-lookup-area-a + request: + method: POST + path: /v2/resources/assistance-enrolment/lookups/by-case-and-person + body: {caseReference: CASE-SYNTH-XFORM, personReference: PERSON-SYNTH-XFORM} + expect: {status: 503, code: source.unavailable} - id: quota-exhausted authorizationFixture: social-lookup-area-a request: diff --git a/products/relay-v2/contracts/acceptance-scenario-matrix.yaml b/products/relay-v2/contracts/acceptance-scenario-matrix.yaml index 8807d7aaa..8f8bdd374 100644 --- a/products/relay-v2/contracts/acceptance-scenario-matrix.yaml +++ b/products/relay-v2/contracts/acceptance-scenario-matrix.yaml @@ -4,10 +4,10 @@ execution: products/relay-v2/scripts/test-http.sh scenarios: - {id: social-ready, project: social-assistance, journeyStep: readiness, assertion: One local protected Registry becomes ready from a generated package.} - {id: social-lookup, project: social-assistance, journeyStep: lookup-success, assertion: A bounded protected lookup returns only its selected governed properties.} - - {id: social-lookup-default, project: social-assistance, journeyStep: lookup-default, assertion: The default representation contains exactly the compiled disclosure profile.} + - {id: social-lookup-default, project: social-assistance, journeyStep: lookup-default, assertion: The default representation contains exactly the compiled disclosure profile and the exact safe partial-string result rather than the source identifier.} - {id: social-caseworker-representation, project: social-assistance, journeyStep: caseworker-representation, assertion: An entitled caseworker explicitly selects its full representation then narrows fields within it.} - - {id: social-unauthorized-representation, project: social-assistance, journeyStep: unauthorized-representation, assertion: A selected caseworker representation is denied without fallback to limited.} - - {id: social-unknown-representation, project: social-assistance, journeyStep: unknown-representation, assertion: An unknown representation is rejected before source access.} + - {id: social-unauthorized-representation, project: social-assistance, journeyStep: unauthorized-representation, assertion: A caller without the selected representation scope receives the same concealed resource outcome without fallback to limited.} + - {id: social-unknown-representation, project: social-assistance, journeyStep: unknown-representation, assertion: An unknown representation receives the same concealed resource outcome as a scope-hidden representation.} - {id: social-duplicate-representation, project: social-assistance, journeyStep: duplicate-representation, assertion: A malformed representation selection is rejected before source access.} - {id: social-lookup-second-subset, project: social-assistance, journeyStep: lookup-second-subset, assertion: A second valid field subset remains governed independently.} - {id: social-lookup-jsonld, project: social-assistance, journeyStep: lookup-jsonld, assertion: JSON and JSON-LD encodings carry equivalent Registry records.} @@ -25,6 +25,7 @@ scenarios: - {id: social-hidden, project: social-assistance, journeyStep: row-hidden, assertion: A row outside the caller binding has the unresolved outcome.} - {id: social-invalid, project: social-assistance, journeyStep: invalid-row, invalidSourceRowClass: missing-required, expectedStatus: 503, expectedCode: source.unavailable, assertion: An invalid selected row fails closed as a source failure.} - {id: social-excessive, project: social-assistance, journeyStep: excessive-row, invalidSourceRowClass: excessive-size, expectedStatus: 503, expectedCode: source.unavailable, assertion: An excessively large source value fails closed as a source failure.} + - {id: social-invalid-transform, project: social-assistance, journeyStep: invalid-transform-input, invalidSourceRowClass: excessive-size, expectedStatus: 503, expectedCode: source.unavailable, assertion: An oversized partial-string source fails atomically as a value-free source failure.} - {id: social-quota, project: social-assistance, journeyStep: quota-exhausted, assertion: The named lookup fails closed after its compiled-operation quota is exhausted.} - {id: business-discovery, project: business-registry, journeyStep: registry-discovery, assertion: Discovery derives only the public Consultation capabilities.} - {id: business-first-page, project: business-registry, journeyStep: first-page, assertion: The snapshot list is paginated and has a cursor.} @@ -36,8 +37,8 @@ scenarios: - {id: business-second-subset, project: business-registry, journeyStep: second-field-subset, assertion: A second valid field subset remains governed independently.} - {id: business-read, project: business-registry, journeyStep: identifier-read, assertion: An identifier read is cacheable under the snapshot posture.} - {id: business-registrar-read, project: business-registry, journeyStep: registrar-read, assertion: The protected registrar representation is selected explicitly and remains no-store.} - - {id: business-registrar-denied, project: business-registry, journeyStep: registrar-representation-denied, assertion: An unentitled caller cannot select the protected registrar representation.} - - {id: business-public-unknown-representation, project: business-registry, journeyStep: public-representation-unknown, assertion: Public discovery cannot turn an unknown representation into a fallback.} + - {id: business-registrar-denied, project: business-registry, journeyStep: registrar-representation-denied, assertion: A caller without the registrar representation scope receives the concealed resource outcome.} + - {id: business-public-unknown-representation, project: business-registry, journeyStep: public-representation-unknown, assertion: Public discovery cannot enumerate an unknown representation or turn it into a fallback.} - {id: business-read-jsonld, project: business-registry, journeyStep: identifier-read-jsonld, assertion: JSON and JSON-LD encodings carry equivalent Registry records.} - {id: business-revalidation, project: business-registry, journeyStep: identifier-read-revalidated, assertion: A matching ETag produces an empty 304 response with the same validator.} - {id: business-unknown-field, project: business-registry, journeyStep: unknown-field, assertion: An unknown public property is rejected before source access.} @@ -54,9 +55,9 @@ scenarios: - {id: civil-read-second-subset, project: civil-event, journeyStep: registrar-read-second-subset, assertion: A second valid field subset remains governed independently.} - {id: civil-read-jsonld, project: civil-event, journeyStep: registrar-read-jsonld, assertion: JSON and JSON-LD encodings carry equivalent Registry records.} - {id: civil-lookup, project: civil-event, journeyStep: lookup-success, assertion: The verification scope receives its distinct disclosure profile.} - - {id: civil-supervisory-date-precision, project: civil-event, journeyStep: supervisory-date-precision, assertion: The supervisory representation returns reviewed date-precision output over the same exact lookup.} - - {id: civil-supervisory-denied, project: civil-event, journeyStep: supervisory-representation-denied, assertion: A registrar-verification grant cannot fall back from a denied supervisory representation.} - - {id: civil-invalid-representation, project: civil-event, journeyStep: invalid-representation, assertion: An invalid civil representation is rejected before lookup execution.} + - {id: civil-supervisory-date-precision, project: civil-event, journeyStep: supervisory-date-precision, assertion: The supervisory representation returns exact reviewed year and year-month outputs with distinct public types and semantic terms over the same exact lookup.} + - {id: civil-supervisory-denied, project: civil-event, journeyStep: supervisory-representation-denied, assertion: A registrar-verification grant receives the concealed resource outcome and cannot fall back from a supervisory representation.} + - {id: civil-invalid-representation, project: civil-event, journeyStep: invalid-representation, assertion: An unknown civil representation receives the concealed resource outcome before lookup execution.} - {id: civil-route, project: civil-event, journeyStep: no-list, assertion: A non-enumerable Registry has no collection route.} - {id: civil-scope, project: civil-event, journeyStep: scope-separation, assertion: A lookup scope cannot synthesize identifier read.} - {id: civil-reverse-scope, project: civil-event, journeyStep: reverse-scope-separation, assertion: A registrar read scope cannot synthesize verification lookup.} @@ -70,4 +71,5 @@ scenarios: - {id: civil-no-match, project: civil-event, journeyStep: no-match, assertion: A missing civil-event lookup has the unresolved outcome.} - {id: civil-ambiguous, project: civil-event, journeyStep: ambiguous, assertion: An ambiguous lookup has the unresolved outcome.} - {id: civil-invalid, project: civil-event, journeyStep: invalid-row, invalidSourceRowClass: unexpected-value, expectedStatus: 503, expectedCode: source.unavailable, assertion: An invalid source row fails closed as a source failure.} + - {id: civil-invalid-transform, project: civil-event, journeyStep: invalid-transform-input, invalidSourceRowClass: unexpected-value, expectedStatus: 503, expectedCode: source.unavailable, assertion: A noncanonical date transform input fails atomically as a value-free source failure.} - {id: civil-quota, project: civil-event, journeyStep: quota-exhausted, assertion: A distinct named lookup has its own bounded quota and value-free refusal.} diff --git a/products/relay-v2/contracts/generated-baselines.yaml b/products/relay-v2/contracts/generated-baselines.yaml index 77db4866a..6e95a90da 100644 --- a/products/relay-v2/contracts/generated-baselines.yaml +++ b/products/relay-v2/contracts/generated-baselines.yaml @@ -2,7 +2,7 @@ schemaVersion: relay.registrystack.org/generated-baselines/v1alpha1 product: relay-v2 projects: social-assistance: - packageRevision: sha256:0e97389ed80f35a90d075f3227365cd37acea51a4df0b30957b6c168617d2bf1 + packageRevision: sha256:bb39e792cbe09e374e5e8fd3ebb468af64b99838158a273a6f48163b0179ae99 contractRevision: sha256:0fd06f53b937afbb0252715010ff222c4cb8817a6c62648a2a72ac4d35eae282 sourceSchemaFingerprints: assistance: sha256:936a90a03d06be67a76226d6999a830c04f6604a3ff8b340a62fdd378d8c6d91 @@ -26,7 +26,7 @@ projects: operationIdentifier: assistance-enrolment.lookup.by-case-and-person path: generated/artifacts/assistance-enrolment--lookup-by-case-and-person--representation-caseworker.context.jsonld representationIdentifier: caseworker - sha256: sha256:17e5183e5fb37679179920acf100116ffa514857a67796986d0a9f62b39e77d3 + sha256: sha256:220f8ac8890bd1167e90c4aa836d75858d89cc0016173227ac7aafaf6a8e9b07 visibility: operation-bound - id: assistance-enrolment--lookup-by-case-and-person--representation-caseworker-processing mediaType: application/json @@ -40,14 +40,14 @@ projects: operationIdentifier: assistance-enrolment.lookup.by-case-and-person path: generated/artifacts/assistance-enrolment--lookup-by-case-and-person--representation-caseworker.schema.json representationIdentifier: caseworker - sha256: sha256:df47a05280064f4e708271690a7906190ada19f1fdc622f38e021c09ba883030 + sha256: sha256:c16f484f5255903f91f86fac25a81d5e7cbcf07ada0a5da1fa3b97c2c5b649cd visibility: operation-bound - id: assistance-enrolment--lookup-by-case-and-person--representation-caseworker-shacl mediaType: text/turtle operationIdentifier: assistance-enrolment.lookup.by-case-and-person path: generated/artifacts/assistance-enrolment--lookup-by-case-and-person--representation-caseworker.shacl.ttl representationIdentifier: caseworker - sha256: sha256:8a9552f08b8dcee6a8d40bab5abe9c0f20baf542d537c8731d0c7e0c34da1cb8 + sha256: sha256:68664354ffccbe112d1a7e06dd68d96d5cd66bc681bc8e44495875b46696c076 visibility: operation-bound - id: assistance-enrolment--lookup-by-case-and-person--representation-caseworker-vocabulary mediaType: application/ld+json @@ -75,7 +75,7 @@ projects: operationIdentifier: assistance-enrolment.lookup.by-case-and-person path: generated/artifacts/assistance-enrolment--lookup-by-case-and-person--representation-limited.context.jsonld representationIdentifier: limited - sha256: sha256:c648dd137278de13ccb21d9b02f0628e8a46fac17327e3fc5ee2b8513da97c9a + sha256: sha256:58aec134af96c8b39f9f0a8dde7c5dd780fedc419637fe1848724a4120d54fc9 visibility: operation-bound - id: assistance-enrolment--lookup-by-case-and-person--representation-limited-processing mediaType: application/json @@ -89,14 +89,14 @@ projects: operationIdentifier: assistance-enrolment.lookup.by-case-and-person path: generated/artifacts/assistance-enrolment--lookup-by-case-and-person--representation-limited.schema.json representationIdentifier: limited - sha256: sha256:8dce934403094548bc7a0666f489d47632834522b53ba61f7082f9e5eb3b807b + sha256: sha256:728f6d4fdc24e33ccad2d36c17d7c403ed5927c93fbbc62578ed13b990c7c6a2 visibility: operation-bound - id: assistance-enrolment--lookup-by-case-and-person--representation-limited-shacl mediaType: text/turtle operationIdentifier: assistance-enrolment.lookup.by-case-and-person path: generated/artifacts/assistance-enrolment--lookup-by-case-and-person--representation-limited.shacl.ttl representationIdentifier: limited - sha256: sha256:173bb033ad5b00f0edb1769f3815f9eee882292e93510d3e529a2629991395f2 + sha256: sha256:ea795cccdde860699ed7998e38cdae8f4dd1784acda936242a82cb62b796f95f visibility: operation-bound - id: assistance-enrolment--lookup-by-case-and-person--representation-limited-vocabulary mediaType: application/ld+json @@ -138,14 +138,14 @@ projects: operationIdentifier: null path: generated/artifacts/assistance-enrolment.full.schema.json representationIdentifier: null - sha256: sha256:48bfebf978cd34b49491fbd0e58d535394f1f224ff701b8cb4587305bf66cf26 + sha256: sha256:4c669711db0b989ac58e1a36ec52afedd05fe2726f7439c0f204fcb0050bb79d visibility: operator-only - id: assistance-enrolment-full-shacl mediaType: text/turtle operationIdentifier: null path: generated/artifacts/assistance-enrolment.full.shacl.ttl representationIdentifier: null - sha256: sha256:0f5bf09cb74494b029e0b8e19ade99d0fe025ec0bee199e03fdd6ae277a1762b + sha256: sha256:53324cf42d1b66d8292897b7d046fe7a68f2c99802c3df7963bb17506ed9e1ad visibility: operator-only - id: assistance-enrolment-full-vocabulary mediaType: application/ld+json @@ -252,7 +252,7 @@ projects: size: 6472 visibility: operator-only business-registry: - packageRevision: sha256:511b9a16ee7985a042a2cda944118d6173ee0f00c98478bf8f3dcc8e33f8fee6 + packageRevision: sha256:6bc260ff78b6600b4c6b019e00a79a41e604cb92adcb64142eb0a15c464ca52d contractRevision: sha256:6c4ea84a578cf589ffb851722830f90d7c241998d82ea419f7b823640cd056b5 sourceSchemaFingerprints: companies: sha256:5f12cd971dfa9cafd98018bd8723f2c3902e7d96b3c57ecf5c20e4885eb806a8 @@ -290,7 +290,7 @@ projects: operationIdentifier: null path: generated/artifacts/registered-business--list--representation-public-register.context.jsonld representationIdentifier: null - sha256: sha256:64bac801dca4b23b1179d6d0e024546c51dc0d910348fcb261b8e69b99aeaf41 + sha256: sha256:a484835aa45107953b758934d5b9d13e47fc8d7c7a06c11fe709ada7e740f2d2 visibility: public - id: registered-business--list--representation-public-register-processing mediaType: application/json @@ -304,14 +304,14 @@ projects: operationIdentifier: null path: generated/artifacts/registered-business--list--representation-public-register.schema.json representationIdentifier: null - sha256: sha256:df98dea2622452d6729268e6d77f2b5bdf5982f2d5f6767189985c6f1dbf870f + sha256: sha256:fc9ae1d572bad5863d99b302c6168807ecfbbb09f0977d075096357d60bf7d78 visibility: public - id: registered-business--list--representation-public-register-shacl mediaType: text/turtle operationIdentifier: null path: generated/artifacts/registered-business--list--representation-public-register.shacl.ttl representationIdentifier: null - sha256: sha256:52f7f327c7eaf3b215590b93679e6a7960218d2822f570c9971e8c001bb8706b + sha256: sha256:61ac61a72c888c6c1850c16ed11b6ec6be0bb96d8f592fc417759f38a1eaaee0 visibility: public - id: registered-business--list--representation-public-register-vocabulary mediaType: application/ld+json @@ -339,7 +339,7 @@ projects: operationIdentifier: registered-business.list path: generated/artifacts/registered-business--list--representation-registrar.context.jsonld representationIdentifier: registrar - sha256: sha256:4ded5db266e71750240973f012a644e1282325fc7a6e7fe717100cc79f16f8db + sha256: sha256:d9c017c057c7228e8145e149494961b7bd8edd4e46c882903af19fbb29d5c960 visibility: operation-bound - id: registered-business--list--representation-registrar-processing mediaType: application/json @@ -353,14 +353,14 @@ projects: operationIdentifier: registered-business.list path: generated/artifacts/registered-business--list--representation-registrar.schema.json representationIdentifier: registrar - sha256: sha256:faf6887e75f88da0552192e58fa918b48b7affebeb1d73268ca40119e24351ba + sha256: sha256:f2dc9e839f20415ad032c657744db091f732d2dc49133884f4d570a580b3fcc5 visibility: operation-bound - id: registered-business--list--representation-registrar-shacl mediaType: text/turtle operationIdentifier: registered-business.list path: generated/artifacts/registered-business--list--representation-registrar.shacl.ttl representationIdentifier: registrar - sha256: sha256:a1942ba407f22a4cda0da41e757781270d5fbe7c489c4329f20f73f06e029c0b + sha256: sha256:c72599e5a94a0c7a8460b3551ef10506cf9e8fa3bd906c828c4a62092ac54581 visibility: operation-bound - id: registered-business--list--representation-registrar-vocabulary mediaType: application/ld+json @@ -381,7 +381,7 @@ projects: operationIdentifier: null path: generated/artifacts/registered-business--read--representation-public-register.context.jsonld representationIdentifier: null - sha256: sha256:64bac801dca4b23b1179d6d0e024546c51dc0d910348fcb261b8e69b99aeaf41 + sha256: sha256:a484835aa45107953b758934d5b9d13e47fc8d7c7a06c11fe709ada7e740f2d2 visibility: public - id: registered-business--read--representation-public-register-processing mediaType: application/json @@ -395,14 +395,14 @@ projects: operationIdentifier: null path: generated/artifacts/registered-business--read--representation-public-register.schema.json representationIdentifier: null - sha256: sha256:727e234dcfac3cbfe0e9f1924412d4f5d06b9fea2b0ed3a8d5b29747fcdbf5ce + sha256: sha256:e57c261ade7808c2974aa86d0bd2d16ac10038b2fe2e0e3b08065099b0e7607c visibility: public - id: registered-business--read--representation-public-register-shacl mediaType: text/turtle operationIdentifier: null path: generated/artifacts/registered-business--read--representation-public-register.shacl.ttl representationIdentifier: null - sha256: sha256:52f7f327c7eaf3b215590b93679e6a7960218d2822f570c9971e8c001bb8706b + sha256: sha256:61ac61a72c888c6c1850c16ed11b6ec6be0bb96d8f592fc417759f38a1eaaee0 visibility: public - id: registered-business--read--representation-public-register-vocabulary mediaType: application/ld+json @@ -430,7 +430,7 @@ projects: operationIdentifier: registered-business.read path: generated/artifacts/registered-business--read--representation-registrar.context.jsonld representationIdentifier: registrar - sha256: sha256:4ded5db266e71750240973f012a644e1282325fc7a6e7fe717100cc79f16f8db + sha256: sha256:d9c017c057c7228e8145e149494961b7bd8edd4e46c882903af19fbb29d5c960 visibility: operation-bound - id: registered-business--read--representation-registrar-processing mediaType: application/json @@ -444,14 +444,14 @@ projects: operationIdentifier: registered-business.read path: generated/artifacts/registered-business--read--representation-registrar.schema.json representationIdentifier: registrar - sha256: sha256:f816daefa266ce44c6fb945eb6bee2fc6a573d9b7104e5d8c7ee1797eb645efc + sha256: sha256:aa6b7009b4a5aa191cce6c057b6a4f3ee7696768c7e21ff8a41802a2f3b44dba visibility: operation-bound - id: registered-business--read--representation-registrar-shacl mediaType: text/turtle operationIdentifier: registered-business.read path: generated/artifacts/registered-business--read--representation-registrar.shacl.ttl representationIdentifier: registrar - sha256: sha256:a1942ba407f22a4cda0da41e757781270d5fbe7c489c4329f20f73f06e029c0b + sha256: sha256:c72599e5a94a0c7a8460b3551ef10506cf9e8fa3bd906c828c4a62092ac54581 visibility: operation-bound - id: registered-business--read--representation-registrar-vocabulary mediaType: application/ld+json @@ -500,14 +500,14 @@ projects: operationIdentifier: null path: generated/artifacts/registered-business.full.schema.json representationIdentifier: null - sha256: sha256:35dbdc7772cdd52829d101e49c18571cf3dbfe8f9737b3a281b9faf3d474a351 + sha256: sha256:8266c4c05a0c304d255de8c69e78bac01b6d2ad86d540cde95e234ca775878fa visibility: operator-only - id: registered-business-full-shacl mediaType: text/turtle operationIdentifier: null path: generated/artifacts/registered-business.full.shacl.ttl representationIdentifier: null - sha256: sha256:6a43f572f14b05968039d17119f534b218eaa649c8a995921f80f47d0b52c098 + sha256: sha256:5800a8e5dc107a5d7260e2567e36504afded088c3335af1a53d0969fdb099270 visibility: operator-only - id: registered-business-full-vocabulary mediaType: application/ld+json @@ -599,8 +599,8 @@ projects: size: 7020 visibility: operator-only civil-event: - packageRevision: sha256:c02ef4ef1c364798ba6dc37fa6c4bc47c78dafbc323046d9ba01db1901ba19d0 - contractRevision: sha256:649c03b1ad1538914fabd918de4617a6ccaa65227ee8d3ebc3b8c385f2348fa7 + packageRevision: sha256:1321aa211982705a63644c45435dc716aacf36b9f0a5872013202fb1985446c1 + contractRevision: sha256:44a3604af965eb51983e44c8cebbb2486cd0655bfe265ce8a47cef22c8f1246a sourceSchemaFingerprints: events: sha256:7f770d64cb19ec54caca2aa56378b13a43cd5edc206ff44b5fecc99ee9e63759 artifacts: @@ -616,21 +616,21 @@ projects: operationIdentifier: null path: generated/artifacts/capabilities.full.json representationIdentifier: null - sha256: sha256:a41bd39464a211c0b69c31fa98a0b512b32a093425252951cfa39c2a9f73a2a9 + sha256: sha256:7ac021239eaf96b23637b3c0fb2606592272ecb46c79d72b2b4fdbf4213a0660 visibility: operator-only - id: capability-inventory mediaType: application/json operationIdentifier: null path: generated/artifacts/capabilities.json representationIdentifier: null - sha256: sha256:cd6407aed23bf39ea3dbc8736da2b8d0a2a2f02042fe06f0fd14c73d2756c246 + sha256: sha256:c944ae58e0b91a404d70d492d46a1fd0ab45a7a455140a65642378c6fe57cdd6 visibility: public - id: civil-event--lookup-verify-registration--representation-registrar-verification-capability mediaType: application/json operationIdentifier: civil-event.lookup.verify-registration path: generated/artifacts/civil-event--lookup-verify-registration--representation-registrar-verification.capability.json representationIdentifier: registrar-verification - sha256: sha256:02441c3f6360721b5d81ba497a871d5f5655a002f5b2b87f55f971cd4407cc4a + sha256: sha256:f90a9e40f3c5c74fb6ac659a9d89526d2e9dfd1d2c43edfc28dba5b6d91014f7 visibility: operation-bound - id: civil-event--lookup-verify-registration--representation-registrar-verification-classifications mediaType: application/json @@ -644,7 +644,7 @@ projects: operationIdentifier: civil-event.lookup.verify-registration path: generated/artifacts/civil-event--lookup-verify-registration--representation-registrar-verification.context.jsonld representationIdentifier: registrar-verification - sha256: sha256:42c276f75d377fc0b86db9ddef7aff6a987c8dd8b3b035036b367ddc779d5cc6 + sha256: sha256:cecc395f6eab42ed11603ded1b76d25980ede8fbe9e2b9adb02a71b8c3a4e423 visibility: operation-bound - id: civil-event--lookup-verify-registration--representation-registrar-verification-processing mediaType: application/json @@ -658,14 +658,14 @@ projects: operationIdentifier: civil-event.lookup.verify-registration path: generated/artifacts/civil-event--lookup-verify-registration--representation-registrar-verification.schema.json representationIdentifier: registrar-verification - sha256: sha256:4a5f34a0cc8804a3d94b0589a86cf4604bff836fa3bbf38b75efabeac3b526d2 + sha256: sha256:9bfbfca6752f1cc81419c49bc2b2ccc9c23ce14a044500b6a6d071d409052bed visibility: operation-bound - id: civil-event--lookup-verify-registration--representation-registrar-verification-shacl mediaType: text/turtle operationIdentifier: civil-event.lookup.verify-registration path: generated/artifacts/civil-event--lookup-verify-registration--representation-registrar-verification.shacl.ttl representationIdentifier: registrar-verification - sha256: sha256:0198029033b327e73fe5774caa7e1ad887df29d2d63869a45e8ad0e562620c09 + sha256: sha256:bd2e2326bc3e25c614dc239aa5bb56ee371f57b6155d8ced40ea2eccfcdaaf7a visibility: operation-bound - id: civil-event--lookup-verify-registration--representation-registrar-verification-vocabulary mediaType: application/ld+json @@ -679,56 +679,56 @@ projects: operationIdentifier: civil-event.lookup.verify-registration path: generated/artifacts/civil-event--lookup-verify-registration--representation-supervisory.capability.json representationIdentifier: supervisory - sha256: sha256:85da4410238a0e08ad671bcef84ac1bfb0819bbc969689cb748b3eaeab5419bd + sha256: sha256:d5cc6a898eb258f73e9c83828f4dbae6e981c528658dfb23b8f87c70c21a0dd1 visibility: operation-bound - id: civil-event--lookup-verify-registration--representation-supervisory-classifications mediaType: application/json operationIdentifier: null path: generated/artifacts/civil-event--lookup-verify-registration--representation-supervisory.classifications.json representationIdentifier: null - sha256: sha256:8d5a6c08fbc15972ec65e1c93d1ef2094322f081d4f4ebb30a367cedf33b9005 + sha256: sha256:4b4c5f7358022430baf7796849f42a89271d8a36273ef59526f3c12116820ba4 visibility: operator-only - id: civil-event--lookup-verify-registration--representation-supervisory-context mediaType: application/ld+json operationIdentifier: civil-event.lookup.verify-registration path: generated/artifacts/civil-event--lookup-verify-registration--representation-supervisory.context.jsonld representationIdentifier: supervisory - sha256: sha256:8934a9f1af201270ce58eb6fc8237e68ab60584d87643387b0a6f3da47ce1766 + sha256: sha256:e4408efdb0ddfed828dc8148f36f86c045ec0f558766ec5275908435dd92c689 visibility: operation-bound - id: civil-event--lookup-verify-registration--representation-supervisory-processing mediaType: application/json operationIdentifier: civil-event.lookup.verify-registration path: generated/artifacts/civil-event--lookup-verify-registration--representation-supervisory.processing.json representationIdentifier: supervisory - sha256: sha256:ed8bbdd4b6111c88dd16b3bf7aaa8a126d71344ca2ebae5f735c5e2fe497917e + sha256: sha256:21c4c9c4d72e9f110247029329be643e18bb96e6aaaac5eb99b6d762b1721b4c visibility: operation-bound - id: civil-event--lookup-verify-registration--representation-supervisory-schema mediaType: application/schema+json operationIdentifier: civil-event.lookup.verify-registration path: generated/artifacts/civil-event--lookup-verify-registration--representation-supervisory.schema.json representationIdentifier: supervisory - sha256: sha256:49b8d47c5caffc83d776d7e3606f9bc6a86c2437048f7928b40552250582676d + sha256: sha256:77cc2dab5ac1334a9bddedd486eb669925fe7064c7ab068622d6204b1992338c visibility: operation-bound - id: civil-event--lookup-verify-registration--representation-supervisory-shacl mediaType: text/turtle operationIdentifier: civil-event.lookup.verify-registration path: generated/artifacts/civil-event--lookup-verify-registration--representation-supervisory.shacl.ttl representationIdentifier: supervisory - sha256: sha256:1ff1315fa0f1f007d3619b6d1826f1af31ac9e11892875e53b6210a536d8eed2 + sha256: sha256:193cdd4cc378c7252c0c734354ef5e9f8f5eea4eafc5a8dcf9729d0a48e69a7a visibility: operation-bound - id: civil-event--lookup-verify-registration--representation-supervisory-vocabulary mediaType: application/ld+json operationIdentifier: civil-event.lookup.verify-registration path: generated/artifacts/civil-event--lookup-verify-registration--representation-supervisory.vocabulary.jsonld representationIdentifier: supervisory - sha256: sha256:be70442b29f25d3cd4455bbc4385512a5990dd787ee48bc4c08f34b04a8913ef + sha256: sha256:f69da73736b4c0847cb66bb1524fb8f6182b7ff71c81eecc73f3588778d172d7 visibility: operation-bound - id: civil-event--read--representation-registrar-capability mediaType: application/json operationIdentifier: civil-event.read path: generated/artifacts/civil-event--read--representation-registrar.capability.json representationIdentifier: registrar - sha256: sha256:5fbb5f391438834f199fc3974daa9e41f990fc0c3210fccfbf76f2874c635e04 + sha256: sha256:5c49bf297ff58f5e4e1cb2c7cc190471c1d56fb5ec4bfc42f5d62938245eb6a2 visibility: operation-bound - id: civil-event--read--representation-registrar-classifications mediaType: application/json @@ -742,7 +742,7 @@ projects: operationIdentifier: civil-event.read path: generated/artifacts/civil-event--read--representation-registrar.context.jsonld representationIdentifier: registrar - sha256: sha256:35d82eab1ee218e40fe01f1981949e19b4ee38b0fde3f2f6f0e4674a75959992 + sha256: sha256:44bc76f5795bbf1fc53b33373b901a5ce1bf612f459a07715db6dd71ae1f2d5d visibility: operation-bound - id: civil-event--read--representation-registrar-processing mediaType: application/json @@ -756,14 +756,14 @@ projects: operationIdentifier: civil-event.read path: generated/artifacts/civil-event--read--representation-registrar.schema.json representationIdentifier: registrar - sha256: sha256:d95b063faa78322eb16624fc0d9ed8eb08025c1206d15cd589f2a81d5f357471 + sha256: sha256:bd423d35379df92607a77594e1befe474a62ba18c3860c3d1455ce1029f02558 visibility: operation-bound - id: civil-event--read--representation-registrar-shacl mediaType: text/turtle operationIdentifier: civil-event.read path: generated/artifacts/civil-event--read--representation-registrar.shacl.ttl representationIdentifier: registrar - sha256: sha256:90cfe36f1e3ea53b8c098555ef4aa048d050cae75b0a4bf18f4efd55d8a9b8ea + sha256: sha256:33cb54f0a1a3a35a132e50a78a40b2c2f7dd5abaf0a7fd768e32fb4a1bb2f390 visibility: operation-bound - id: civil-event--read--representation-registrar-vocabulary mediaType: application/ld+json @@ -777,7 +777,7 @@ projects: operationIdentifier: null path: generated/artifacts/civil-event.classifications.json representationIdentifier: null - sha256: sha256:89397956719cb52cadf2ae045a2c4cd86487ed4841aa3ff2ac958b0cbfc13e54 + sha256: sha256:f5bbe318289cea6113fcf05873855ca972c0e0a1796d66f2b9ec28265cdaa25d visibility: operator-only - id: civil-event-codelist-0 mediaType: application/schema+json @@ -812,21 +812,21 @@ projects: operationIdentifier: null path: generated/artifacts/civil-event.full.schema.json representationIdentifier: null - sha256: sha256:891f7eb10a59b8b8022d45fbceae485c5b17d769f6a4d582c1014009fd140cb3 + sha256: sha256:8719ed7bf8ccb512b1da1a2ed33e70308a4f2e75d919d3b62d071ba6e76a8bbb visibility: operator-only - id: civil-event-full-shacl mediaType: text/turtle operationIdentifier: null path: generated/artifacts/civil-event.full.shacl.ttl representationIdentifier: null - sha256: sha256:904488714d833929b35a74de0017455c5ea9018af020a81367ed033f63f8b062 + sha256: sha256:fbb6bb7991d85d5de37e5d4115318c43dc108a1a61f3449496a635221a435642 visibility: operator-only - id: civil-event-full-vocabulary mediaType: application/ld+json operationIdentifier: null path: generated/artifacts/civil-event.full.vocabulary.jsonld representationIdentifier: null - sha256: sha256:0d5901e0b9479f0482022cb5a1b4d7af90bfb2bf4b087b56cc46d6550c2b3b86 + sha256: sha256:437d021d8cd85c4e7847dc9df983c7375a8332efb0b5ac77ce7a5fda4fda3b58 visibility: operator-only - id: civil-event-processing-full mediaType: application/json @@ -840,7 +840,7 @@ projects: operationIdentifier: null path: generated/openapi.full.yaml representationIdentifier: null - sha256: sha256:0192677820ef2d9ba0d0bd1548fab606badd415763195407e6a48fca79f89211 + sha256: sha256:04b9fc444ace2a9e3062f82fdf3e9cbd3d375422c109dd0c3bdd42fe7b8efdf4 visibility: operator-only - id: openapi-public mediaType: application/json @@ -883,13 +883,13 @@ projects: - generated: false mediaType: application/yaml path: governed/governance/classification-review-rationale.md - sha256: sha256:23a1ae8431bf2979f60a60191407a832b2d4fbae035bc3ca471465590b6243be - size: 185 + sha256: sha256:bbd8dbc65fb78549df5f425d54f073182d2a7a977f4fafbf10d7ace0dde7f514 + size: 258 visibility: operator-only - generated: false mediaType: application/yaml path: governed/governance/classification-review.yaml - sha256: sha256:7de2425d26c6b841a44fa4651eb336b07eb74d5d564d502e9298536416904558 + sha256: sha256:9ccdc02c3564c793540c21dc1a86d50a03ed890c83f54ec4d6ed1f0e06855864 size: 423 visibility: operator-only - generated: false @@ -913,6 +913,6 @@ projects: - generated: false mediaType: application/yaml path: registry.yaml - sha256: sha256:59916fb89188b9eda0fddde75f96dee7f25027b694849c648f94acd023a424e1 - size: 8151 + sha256: sha256:6c6298aec06bb136d5851fc66fc0aa4d77bc7cf17e9ace2bf1bed35c6b387d4a + size: 8690 visibility: operator-only diff --git a/products/relay-v2/contracts/security-invariant-matrix.yaml b/products/relay-v2/contracts/security-invariant-matrix.yaml index db33090ca..487627dc7 100644 --- a/products/relay-v2/contracts/security-invariant-matrix.yaml +++ b/products/relay-v2/contracts/security-invariant-matrix.yaml @@ -77,7 +77,7 @@ invariants: - id: sec-classification-review-binding threat: A generated, stale, tampered, uncertain, or mismatched classification review silently authorizes a changed disclosure contract. enforcementPoint: Governed-file review parsing and compilation against the canonical classification inventory digest. - negativeCase: stale_or_unreviewed_classification_review_is_refused + negativeTest: stale_review_fails_production_but_remains_an_authoring_finding expected: Production compilation accepts only reviewed inventory-bound evidence; generated review additionally binds the accepted report and exact rule pack, while manual and imported review do not require a report. evidence: review-binding-compiler-tests tests: @@ -86,8 +86,8 @@ invariants: - id: sec-finite-representation-authorization threat: A request selects an undeclared, malformed, or denied representation, crosses profiles with fields, or falls back to a different disclosure. enforcementPoint: Closed compiled representation map, one exact default, pre-source selection, and selected-profile field validation. - negativeCase: representation_selection_or_cross_profile_fields_fall_back_or_reach_source - expected: An operation has one declared default and finite names; access and disclosure are evaluated only for the exact selection, and fields cannot cross the selected profile. + negativeTest: representation_selection_authenticates_then_authorizes_the_exact_profile + expected: An operation has one declared default and finite names; access and disclosure are evaluated only for the exact selection, unknown and scope-hidden names are indistinguishable as resource.not_found, and fields cannot cross the selected profile. evidence: compiler-and-real-router-representation-tests tests: - {path: crates/registry-relay-v2/src/compiler.rs, name: representation_default_and_transform_parameters_fail_closed} @@ -97,28 +97,29 @@ invariants: - id: sec-public-representation-processing-floor threat: A public masked or minimized representation reads a confidential or restricted raw source column. enforcementPoint: Per-representation processed-column closure and processing-handling compilation before route activation. - negativeCase: public_representation_processes_nonpublic_raw_source + negativeTest: public_masked_representation_cannot_process_restricted_source expected: Anonymous release requires public processing handling, including transform inputs and every selector, filter, order, and Registry Core source column. evidence: compiler-handling-tests tests: - {path: crates/registry-relay-v2/src/compiler.rs, name: public_masked_representation_cannot_process_restricted_source} - {path: crates/registry-relay-v2/src/compiler.rs, name: public_operation_cannot_process_nonpublic_columns} - id: sec-closed-mask-and-date-transforms - threat: Caller-directed or malformed transform input releases a raw, complete, noncanonical, wrongly typed, or over-precise value. - enforcementPoint: Compiled transform catalog and value-free source failure before response serialization. - negativeCase: transform_reveals_or_serializes_incompatible_input - expected: Only bounded partial-string with the Relay-owned marker and typed date-precision execute; short strings never reveal complete input and incompatible date input fails closed. + threat: Caller-directed or malformed transform input releases a raw, complete, noncanonical, wrongly typed, or over-precise value, or a transformed list filter or order key becomes an oracle over raw source values. + enforcementPoint: Response-only compiled transform catalog, compile-time filter and order exclusion, and value-free source failure before response serialization. + negativeTest: transforms_are_bounded_value_free_and_terminal_audit_gates_exact_bytes + expected: Only bounded partial-string with the Relay-owned marker and typed date-precision execute as response transforms; transformed properties can never be list filters or order keys, short strings never reveal complete input, and malformed selected transform input discards the whole response as value-free 503 source.unavailable. evidence: transform-unit-and-compiler-tests tests: - {path: crates/registry-relay-v2/src/transform.rs, name: partial_string_never_reveals_a_complete_short_input} - {path: crates/registry-relay-v2/src/transform.rs, name: partial_string_counts_unicode_scalars} - {path: crates/registry-relay-v2/src/transform.rs, name: date_precision_accepts_only_the_compiled_source_shape} - {path: crates/registry-relay-v2/src/compiler.rs, name: date_precision_is_typed_and_closed} + - {path: crates/registry-relay-v2/src/compiler.rs, name: transformed_properties_cannot_be_list_filters_or_order_keys} - {path: crates/registry-relay-v2/tests/representation_http.rs, name: transforms_are_bounded_value_free_and_terminal_audit_gates_exact_bytes} - id: sec-representation-state-and-metadata-binding threat: A cursor, ETag, metadata route, artifact, or quota crosses a representation boundary or reveals a protected profile. enforcementPoint: Representation-bound cursor and cache identity, exact representation artifact gates, and operation-owned quota state. - negativeCase: profile_state_or_metadata_crosses_representation_boundary + negativeTest: cursor_and_etag_are_bound_to_selected_representation expected: Cursor and ETag reuse across profiles fails; metadata and artifacts authorize one representation exactly; adding profiles does not multiply the operation quota. evidence: real-router-representation-state-tests tests: @@ -161,14 +162,15 @@ invariants: - {path: crates/registry-relay-v2/src/problem.rs, name: unresolved_lookup_causes_have_one_public_body} - {path: crates/registry-relay-v2/tests/acceptance_http.rs, name: all_three_registry_http_journeys_use_the_real_router} - id: sec-malformed-row-atomicity - threat: A malformed selected source row is coerced, skipped, partially released, or mistaken for a normal unresolved lookup. - enforcementPoint: Full source-row and cursor-order validation occurs before response serialization, followed by a source-failed terminal audit gate. - expected: Every malformed selected row discards the entire held response and returns value-free 503 source.unavailable. + threat: A malformed selected source row or transform input is coerced, skipped, partially released, or mistaken for a normal unresolved lookup. + enforcementPoint: Full source-row, transform-input, and cursor-order validation occurs before response serialization, followed by a source-failed terminal audit gate. + expected: Every malformed selected row, including a transform failure, discards the entire held response and returns value-free 503 source.unavailable. evidence: real-router-malformed-row-tests negativeTest: malformed_disclosed_property_type_and_requiredness_fail_closed tests: - {path: crates/registry-relay-v2/tests/acceptance_http.rs, name: malformed_disclosed_property_type_and_requiredness_fail_closed} - {path: crates/registry-relay-v2/tests/acceptance_http.rs, name: business_list_with_a_late_malformed_row_fails_atomically} + - {path: crates/registry-relay-v2/tests/representation_http.rs, name: transforms_are_bounded_value_free_and_terminal_audit_gates_exact_bytes} - id: sec-reference-visibility threat: A Record leaks protected metadata or points its caller to an unresolvable schema or semantic model. enforcementPoint: Compile-time visibility closure across operation, resource, schema, semantic, capability, and OpenAPI artifacts. @@ -207,12 +209,15 @@ invariants: - {path: crates/registry-relay-v2/src/cursor.rs, name: cursor_refuses_every_mismatched_request_binding_and_expiry} - id: sec-source-truthfulness threat: Relay serves incompatible schema, mixed live state, or false revision and cache claims. - enforcementPoint: Schema fingerprint, one transaction per request, source-profile revisions, and path identity checks. - expected: Relay reports only revisions it can establish and never claims snapshot consistency for unversioned live data. + enforcementPoint: Schema fingerprint, one transaction per request, source-profile revisions, path identity checks, and per-execution snapshot digest verification before and after query execution. + expected: Relay reports only revisions it can establish, re-verifies snapshot content before and after every execution even when file identity is unchanged, and never claims snapshot consistency for unversioned live data. evidence: source-profile-tests - negativeTest: live_reads_allow_content_updates_but_refuse_path_replacement + negativeTest: snapshot_digest_is_rechecked_after_the_statement_finishes tests: - {path: crates/registry-platform-sqlite/tests/kernel.rs, name: a_snapshot_is_digest_bound_and_read_immutably} + - {path: crates/registry-platform-sqlite/tests/kernel.rs, name: async_snapshot_execution_refuses_same_inode_content_drift} + - {path: crates/registry-platform-sqlite/tests/kernel.rs, name: startup_snapshot_execution_refuses_same_inode_content_drift} + - {path: crates/registry-platform-sqlite/src/statement.rs, name: snapshot_digest_is_rechecked_after_the_statement_finishes} - {path: crates/registry-platform-sqlite/tests/kernel.rs, name: live_reads_allow_content_updates_but_refuse_path_replacement} - {path: crates/registry-platform-sqlite/tests/kernel.rs, name: row_cell_and_response_bounds_are_enforced} - {path: crates/registry-relay-v2/tests/acceptance_http.rs, name: readiness_fails_value_free_for_missing_replaced_and_drifted_sources} diff --git a/products/relay-v2/scripts/test_validate_product.py b/products/relay-v2/scripts/test_validate_product.py index f7c9af462..f48cc61d5 100644 --- a/products/relay-v2/scripts/test_validate_product.py +++ b/products/relay-v2/scripts/test_validate_product.py @@ -162,6 +162,76 @@ def load_with_unresolved_source_row(path: Path): any("expectation disagrees with the journey" in error for error in errors), errors ) + def test_invalid_transform_scenario_must_remain_a_source_failure(self) -> None: + original = VALIDATOR.load_yaml + + def load_with_unresolved_transform(path: Path): + value = copy.deepcopy(original(path)) + if path.name == "acceptance-scenario-matrix.yaml": + scenario = next( + item + for item in value["scenarios"] + if item.get("id") == "social-invalid-transform" + ) + scenario["expectedStatus"] = 404 + scenario["expectedCode"] = "consultation.unresolved" + return value + + errors: list[str] = [] + with mock.patch.object( + VALIDATOR, "load_yaml", side_effect=load_with_unresolved_transform + ): + VALIDATOR.validate_catalogs(errors) + self.assertTrue( + any("must expect 503 source.unavailable" in error for error in errors), errors + ) + + def test_both_bounded_transforms_require_a_failure_scenario(self) -> None: + original = VALIDATOR.load_yaml + + def load_without_civil_transform_scenario(path: Path): + value = copy.deepcopy(original(path)) + if path.name == "acceptance-scenario-matrix.yaml": + scenario = next( + item + for item in value["scenarios"] + if item.get("id") == "civil-invalid-transform" + ) + scenario["id"] = "civil-transform-failure-renamed" + return value + + errors: list[str] = [] + with mock.patch.object( + VALIDATOR, "load_yaml", side_effect=load_without_civil_transform_scenario + ): + VALIDATOR.validate_catalogs(errors) + self.assertTrue( + any("both bounded transforms require" in error for error in errors), errors + ) + + def test_unknown_and_scope_hidden_representations_share_one_outcome(self) -> None: + original = VALIDATOR.load_yaml + + def load_with_enumerable_unknown_representation(path: Path): + value = copy.deepcopy(original(path)) + if path.name == "expected-http.yaml" and path.parent.name == "social-assistance": + step = next( + item + for item in value["steps"] + if item.get("id") == "unknown-representation" + ) + step["expect"]["code"] = "representation.not_found" + return value + + errors: list[str] = [] + with mock.patch.object( + VALIDATOR, "load_yaml", side_effect=load_with_enumerable_unknown_representation + ): + VALIDATOR.validate_catalogs(errors) + self.assertTrue( + any("must conceal representation existence" in error for error in errors), errors + ) + def test_security_test_resolution_rejects_a_similar_prefix(self) -> None: errors: list[str] = [] VALIDATOR.executable_test_resolves( diff --git a/products/relay-v2/scripts/validate_product.py b/products/relay-v2/scripts/validate_product.py index c2e7a99f6..102053a19 100644 --- a/products/relay-v2/scripts/validate_product.py +++ b/products/relay-v2/scripts/validate_product.py @@ -29,6 +29,15 @@ "unexpected-value", "excessive-size", } +TRANSFORM_FAILURE_SCENARIOS = { + "social-invalid-transform", + "civil-invalid-transform", +} +REPRESENTATION_CONCEALMENT_STEPS = { + "social-assistance": {"unauthorized-representation", "unknown-representation"}, + "business-registry": {"registrar-representation-denied", "public-representation-unknown"}, + "civil-event": {"supervisory-representation-denied", "invalid-representation"}, +} SECURITY_INVARIANT_IDS = { "sec-contract-runtime-separation", "sec-package-activation-integrity", @@ -38,6 +47,11 @@ "sec-token-profile-closed", "sec-resource-existence-concealment", "sec-operation-confinement", + "sec-classification-review-binding", + "sec-finite-representation-authorization", + "sec-public-representation-processing-floor", + "sec-closed-mask-and-date-transforms", + "sec-representation-state-and-metadata-binding", "sec-operation-quota", "sec-trusted-context", "sec-disclosure-monotonic", @@ -381,6 +395,12 @@ def validate_catalogs(errors: list[str]) -> None: errors.append(f"artifact inventory: missing {required}") steps = journey_steps(errors) + for project, concealed_steps in REPRESENTATION_CONCEALMENT_STEPS.items(): + for step in concealed_steps: + if steps.get(project, {}).get(step) != (404, "resource.not_found"): + errors.append( + f"{project}: {step} must conceal representation existence as 404 resource.not_found" + ) scenarios = mapping( load_yaml(PRODUCT_ROOT / "contracts/acceptance-scenario-matrix.yaml"), "scenario matrix", @@ -445,6 +465,10 @@ def validate_catalogs(errors: list[str]) -> None: "acceptance journeys: invalid source-row classes must cover " + ", ".join(sorted(INVALID_SOURCE_ROW_CLASSES)) ) + if not TRANSFORM_FAILURE_SCENARIOS.issubset(scenario_ids): + errors.append( + "acceptance journeys: both bounded transforms require an atomic source-failure scenario" + ) matrix = mapping( load_yaml(PRODUCT_ROOT / "contracts/security-invariant-matrix.yaml"), From 964b185f6e868f00f38ed9d6abe13ccae90f4c1e Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 10 Aug 2026 15:29:15 +0700 Subject: [PATCH 13/24] test(relay): bind JSON-LD context references Signed-off-by: Jeremi Joslin --- .../tests/acceptance_http.rs | 60 ++++++++++++------- 1 file changed, 37 insertions(+), 23 deletions(-) diff --git a/crates/registry-relay-v2/tests/acceptance_http.rs b/crates/registry-relay-v2/tests/acceptance_http.rs index 485b5ac10..193ca034c 100644 --- a/crates/registry-relay-v2/tests/acceptance_http.rs +++ b/crates/registry-relay-v2/tests/acceptance_http.rs @@ -1526,6 +1526,43 @@ fn validate_json_ld_graph( document: &Value, binding: ®istry_relay_v2::artifacts::OperationArtifactBindings, ) { + let resource = harness + .service + .registry + .resources + .iter() + .find(|resource| { + resource + .operations + .iter() + .any(|operation| operation.identifier == binding.operation_identifier) + }) + .expect("compiled operation belongs to one resource"); + let representation = resource + .operations + .iter() + .find(|operation| operation.identifier == binding.operation_identifier) + .and_then(|operation| { + operation + .representations + .iter() + .find(|representation| representation.id == binding.representation_identifier) + }) + .expect("compiled operation carries the selected representation"); + assert_eq!( + document.get("@context").and_then(Value::as_str), + Some(representation.context_reference.as_str()), + "{project}/{} JSON-LD response must name the selected representation context", + step.id + ); + assert_eq!( + document + .pointer("/meta/links/context") + .and_then(Value::as_str), + Some(representation.context_reference.as_str()), + "{project}/{} response metadata must name the selected representation context", + step.id + ); let context_artifact = harness .service .artifacts @@ -1557,29 +1594,6 @@ fn validate_json_ld_graph( step.id ); - let resource = harness - .service - .registry - .resources - .iter() - .find(|resource| { - resource - .operations - .iter() - .any(|operation| operation.identifier == binding.operation_identifier) - }) - .expect("compiled operation belongs to one resource"); - let representation = resource - .operations - .iter() - .find(|operation| operation.identifier == binding.operation_identifier) - .and_then(|operation| { - operation - .representations - .iter() - .find(|representation| representation.id == binding.representation_identifier) - }) - .expect("compiled operation carries the selected representation"); let shacl = std::str::from_utf8( &harness .service From 061430da8690886924c7a4732186a906aaebdff4 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 10 Aug 2026 15:39:55 +0700 Subject: [PATCH 14/24] test(evidence): stabilize SQLite deadline proof Signed-off-by: Jeremi Joslin --- crates/registry-evidence/src/source_sqlite.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/registry-evidence/src/source_sqlite.rs b/crates/registry-evidence/src/source_sqlite.rs index 23da907ed..aadf315d4 100644 --- a/crates/registry-evidence/src/source_sqlite.rs +++ b/crates/registry-evidence/src/source_sqlite.rs @@ -1134,10 +1134,13 @@ factSchema: schemas/facts.schema.yaml async fn the_time_budget_stops_a_slow_statement() { let directory = TempDir::new().expect("a temporary directory"); let path = extract(&directory); + // Opening verifies the extract metadata under the same configured + // budget. Leave setup margin, then keep the step ceiling high enough + // that the execution deadline is the first statement limit reached. let plan = Plan::default() .columns("[{name: total, type: integer}]") - .steps(1_000_000) - .timeout(1); + .steps(100_000_000) + .timeout(50); let source = open( &plan, "WITH RECURSIVE counter(n) AS ( From fa26bd8bd92bbbe1c4aac61b42de4275899ca3a5 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 10 Aug 2026 15:51:28 +0700 Subject: [PATCH 15/24] test(evidence): serialize loopback port handoff Signed-off-by: Jeremi Joslin --- .../tests/against_a_real_deployment.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/crates/registry-evidence-client/tests/against_a_real_deployment.rs b/crates/registry-evidence-client/tests/against_a_real_deployment.rs index c2ff6629b..f67325715 100644 --- a/crates/registry-evidence-client/tests/against_a_real_deployment.rs +++ b/crates/registry-evidence-client/tests/against_a_real_deployment.rs @@ -79,6 +79,13 @@ const ES256_CLIENT_KEY_ID: &str = "client-suite-client-key-es256"; /// refresh margin case needs a margin wider than a whole credential's life. const ISSUED_TOKEN_LIFETIME_SECONDS: i64 = 60; +/// Serialize the narrow handoff from a held ephemeral port to a real service. +/// +/// The services under test have to know their configured port before binding it. +/// Without this guard, another parallel case in this test binary can reserve the +/// just-released port before the spawned service reaches `bind`. +static LOOPBACK_PORT_HANDOFF: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + /// Prefix the runtime gives every published subject binding. const BINDING_PREFIX: &str = "urn:evidence:subject:v1_"; @@ -1040,6 +1047,7 @@ async fn start_trusting(source_answer: Value, external_issuer: Option<&str>) -> ); let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); let served = Arc::clone(&runtime); + let port_handoff = LOOPBACK_PORT_HANDOFF.lock().await; drop(reservation); let server = tokio::spawn(async move { server::serve(served, async { @@ -1069,6 +1077,7 @@ async fn start_trusting(source_answer: Value, external_issuer: Option<&str>) -> &deployment.server, ) .await; + drop(port_handoff); deployment } @@ -1302,6 +1311,7 @@ clients: .expect("the staged issuer loads"), ); let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); + let port_handoff = LOOPBACK_PORT_HANDOFF.lock().await; drop(reservation); let server = tokio::spawn(async move { mint_server::serve(service, async { @@ -1326,6 +1336,7 @@ clients: &issuer.server, ) .await; + drop(port_handoff); issuer } From 545efaf9597647744ada29fdb43759a4d1fac996 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 10 Aug 2026 10:42:16 +0700 Subject: [PATCH 16/24] feat(relay): add governed spatial point profile Signed-off-by: Jeremi Joslin --- crates/registry-relay-v2/src/api.rs | 627 +++++++++++++++--- crates/registry-relay-v2/src/artifacts.rs | 585 +++++++++++++++- crates/registry-relay-v2/src/compiler.rs | 558 +++++++++++++++- crates/registry-relay-v2/src/contract.rs | 38 ++ crates/registry-relay-v2/src/cursor.rs | 68 ++ crates/registry-relay-v2/src/diff.rs | 290 +++++++- .../registry-relay-v2/src/fixture_contract.rs | 28 + crates/registry-relay-v2/src/fixtures.rs | 345 +++++++++- crates/registry-relay-v2/src/model.rs | 35 + crates/registry-relay-v2/src/semantics.rs | 140 +++- .../registry-relay-v2/src/sqlite_runtime.rs | 156 +++++ .../tests/acceptance_http.rs | 404 ++++++++++- .../tests/representation_http.rs | 4 + products/relay-v2/CONCEPT.md | 52 +- products/relay-v2/CONFIGURATION-EXAMPLES.md | 102 +++ products/relay-v2/DEFINITION-OF-DONE.md | 18 +- products/relay-v2/IMPLEMENTATION.md | 43 +- products/relay-v2/README.md | 2 + products/relay-v2/STANDARDS-ALIGNMENT.md | 7 + .../business-registry/expected-http.yaml | 173 ++++- .../acceptance/business-registry/fixture.sql | 29 + .../governance/classification-review.yaml | 2 +- .../business-registry/registry.yaml | 73 +- .../semantics/local-vocabulary.yaml | 5 + .../semantics/semic-business-alignment.yaml | 2 + .../contracts/acceptance-scenario-matrix.yaml | 18 + .../contracts/artifact-inventory.yaml | 6 + .../contracts/generated-baselines.yaml | 190 +++++- .../contracts/security-invariant-matrix.yaml | 21 + products/relay-v2/scripts/validate_product.py | 2 + 30 files changed, 3823 insertions(+), 200 deletions(-) diff --git a/crates/registry-relay-v2/src/api.rs b/crates/registry-relay-v2/src/api.rs index e96427fa6..b305f69c9 100644 --- a/crates/registry-relay-v2/src/api.rs +++ b/crates/registry-relay-v2/src/api.rs @@ -7,7 +7,7 @@ use std::sync::Arc; use axum::body::{to_bytes, Body}; use axum::extract::{Path, State}; -use axum::http::header::{ACCEPT, CACHE_CONTROL, CONTENT_TYPE, ETAG, IF_NONE_MATCH, VARY}; +use axum::http::header::{ACCEPT, CACHE_CONTROL, CONTENT_TYPE, ETAG, IF_NONE_MATCH, LINK, VARY}; use axum::http::{HeaderMap, HeaderValue, Request, Response, StatusCode, Uri}; use chrono::{DateTime, NaiveDate}; use registry_platform_canonical_json::canonicalize_json; @@ -25,12 +25,12 @@ use crate::cursor::{ CursorBindings, CursorPayload, CursorValue, }; use crate::model::{ - CompiledAccess, CompiledOperation, CompiledRepresentation, CompiledResource, OperationKind, - RowAuthoritySource, + CompiledAccess, CompiledOperation, CompiledRepresentation, CompiledResource, + ConsultationPattern, OperationKind, RepresentationProfile, RowAuthoritySource, }; use crate::problem::{ProblemCode, TraceContext}; use crate::server::{uri_within_bound, RelayService}; -use crate::sqlite_runtime::{OperationQuery, SourceRevision, SqliteRuntimeError}; +use crate::sqlite_runtime::{OperationQuery, PointBbox, SourceRevision, SqliteRuntimeError}; use crate::transform; const PRODUCT_NAME: &str = "Registry Relay"; @@ -40,11 +40,24 @@ const API_BINDING_VERSION: &str = "v2"; const METADATA_DEFAULT_PAGE_SIZE: usize = 50; const METADATA_MAXIMUM_PAGE_SIZE: usize = 100; const MAXIMUM_SERIALIZED_RESPONSE_BYTES: usize = 8 * 1024 * 1024; +const JSON_FG_PROFILE_URI: &str = "http://www.opengis.net/def/profile/OGC/0/jsonfg"; +const RFC_7946_PROFILE_URI: &str = "http://www.opengis.net/def/profile/OGC/0/rfc7946"; +const CRS84_URI: &str = "http://www.opengis.net/def/crs/OGC/0/CRS84"; +const JSON_FG_CORE_CONFORMANCE: &str = "http://www.opengis.net/spec/json-fg-1/1.0/conf/core"; +const JSON_FG_TYPES_SCHEMAS_CONFORMANCE: &str = + "http://www.opengis.net/spec/json-fg-1/1.0/conf/types-schemas"; #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum ResponseFormat { Json, JsonLd, + GeoJson(GeoJsonProfile), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum GeoJsonProfile { + Rfc7946, + JsonFg, } impl ResponseFormat { @@ -52,6 +65,31 @@ impl ResponseFormat { match self { Self::Json => "application/json", Self::JsonLd => "application/ld+json", + Self::GeoJson(_) => "application/geo+json", + } + } + + const fn cursor_kind(self) -> &'static str { + match self { + Self::Json => "json", + Self::JsonLd => "json-ld", + Self::GeoJson(_) => "geojson", + } + } + + const fn cursor_profile(self) -> Option<&'static str> { + match self { + Self::GeoJson(GeoJsonProfile::Rfc7946) => Some("rfc7946"), + Self::GeoJson(GeoJsonProfile::JsonFg) => Some("jsonfg"), + Self::Json | Self::JsonLd => None, + } + } + + const fn profile_link(self) -> Option<&'static str> { + match self { + Self::GeoJson(GeoJsonProfile::JsonFg) => Some(JSON_FG_PROFILE_URI), + Self::GeoJson(GeoJsonProfile::Rfc7946) => Some(RFC_7946_PROFILE_URI), + Self::Json | Self::JsonLd => None, } } } @@ -436,7 +474,7 @@ pub async fn record_list( ) .await; } - let response_format = match negotiate(&headers) { + let response_format = match negotiate(&headers, resource, &access.representation) { Ok(value) => value, Err(code) => { return refuse_known( @@ -451,7 +489,14 @@ pub async fn record_list( .await } }; - let query = match prepare_list(&service, resource, operation, &access, uri.query()) { + let query = match prepare_list( + &service, + resource, + operation, + &access, + response_format, + uri.query(), + ) { Ok(value) => value, Err(code) => { return refuse_known( @@ -499,6 +544,7 @@ pub async fn record_list( row_authority: access.authorization.row_authority.clone(), after_order: query.after_order.clone(), fetch_limit: Some(query.page_size.saturating_add(1)), + bbox: query.bbox, ..OperationQuery::default() }, ) @@ -559,30 +605,36 @@ pub async fn record_list( } else { None }; - let mut document = json!({ - "items": items, - "pageInfo": {"nextCursor": next_cursor}, - "meta": record_meta( - &service, - resource, - operation, - &access.representation, - &query.selected_fields, - &result.source_revision, - ), - }); + let meta = record_meta( + &service, + resource, + operation, + &access.representation, + &query.selected_fields, + &result.source_revision, + ); + let mut document = match query.response_format { + ResponseFormat::GeoJson(profile) => { + geojson_collection(&service, resource, items, next_cursor, meta, profile) + } + ResponseFormat::Json | ResponseFormat::JsonLd => json!({ + "items": items, + "pageInfo": {"nextCursor": next_cursor}, + "meta": meta, + }), + }; apply_json_ld( &service, resource, &access.representation, - response_format, + query.response_format, &mut document, ); release_document( &service, &audit, document, - response_format, + query.response_format, cacheable(&access.representation, &result.source_revision), &headers, &trace, @@ -719,25 +771,11 @@ pub async fn record_lookup( ) .await; } - let response_format = match negotiate(request.headers()) { - Ok(value) => value, - Err(code) => { - return refuse_known( - &service, - resource, - operation, - Some(&access), - AuditOutcome::InvalidRequest, - code, - &trace, - ) - .await - } - }; - let fields = match selected_fields( + let (response_format, fields) = match prepare_single_request( resource, operation, &access.representation, + request.headers(), request.uri().query(), ) { Ok(value) => value, @@ -885,25 +923,11 @@ async fn single_operation( ) .await; } - let representation = match negotiate(headers) { - Ok(value) => value, - Err(code) => { - return refuse_known( - service, - resource, - operation, - Some(&access), - AuditOutcome::InvalidRequest, - code, - trace, - ) - .await - } - }; - let fields = match selected_fields( + let (representation, fields) = match prepare_single_request( resource, operation, &access.representation, + headers, request.query_text, ) { Ok(value) => value, @@ -977,17 +1001,23 @@ async fn single_operation( return source_shape_failure(&service.audit, &audit, trace).await; } }; - let mut document = json!({ - "data": record, - "meta": record_meta( - service, - resource, - operation, - &access.representation, - &fields, - &result.source_revision, - ), - }); + let meta = record_meta( + service, + resource, + operation, + &access.representation, + &fields, + &result.source_revision, + ); + let mut document = match representation { + ResponseFormat::GeoJson(profile) => { + geojson_feature(service, resource, record, Some(meta), profile, true) + } + ResponseFormat::Json | ResponseFormat::JsonLd => json!({ + "data": record, + "meta": meta, + }), + }; apply_json_ld( service, resource, @@ -1445,6 +1475,16 @@ struct PreparedList { filters: BTreeMap, selected_fields: Vec, after_order: Option>, + bbox: Option, + response_format: ResponseFormat, +} + +struct CursorQueryContext<'a> { + filters: &'a BTreeMap, + selected_fields: &'a [String], + source_revision: &'a str, + bbox: Option, + response_format: ResponseFormat, } struct SelectedRepresentation<'a> { @@ -1546,6 +1586,7 @@ fn prepare_list( resource: &CompiledResource, operation: &CompiledOperation, access: &Access, + negotiated: ResponseFormat, query: Option<&str>, ) -> Result { let parameters = parse_query(query)?; @@ -1583,13 +1624,24 @@ fn prepare_list( .iter() .map(|(name, value)| (name.clone(), cursor_to_sql(value.clone()))) .collect::>(); - validate_filter_inventory(operation, &filters)?; + let bbox = payload + .bbox + .as_ref() + .map(|values| parse_bbox_values(operation, values)) + .transpose() + .map_err(|_| ProblemCode::CursorInvalid)?; + validate_filter_inventory(operation, &filters, bbox.is_some())?; validate_selected_inventory( resource, operation, &access.representation, &payload.selected_fields, )?; + let response_format = + response_format_from_cursor(resource, &access.representation, &payload)?; + if response_format.cursor_kind() != negotiated.cursor_kind() { + return Err(ProblemCode::CursorInvalid); + } let current_source_revision = service .sqlite .source_revision(&operation.identifier) @@ -1599,9 +1651,13 @@ fn prepare_list( service, operation, access, - &filters, - &payload.selected_fields, - ¤t_source_revision, + CursorQueryContext { + filters: &filters, + selected_fields: &payload.selected_fields, + source_revision: ¤t_source_revision, + bbox, + response_format, + }, )?; require_same_request(&payload, &request).map_err(|_| ProblemCode::CursorInvalid)?; return Ok(PreparedList { @@ -1615,12 +1671,16 @@ fn prepare_list( .map(cursor_to_sql) .collect(), ), + bbox, + response_format, }); } let mut page_size = pagination.default_page_size; let mut page_size_seen = false; let mut fields_text = None; + let mut profile_text = None; + let mut bbox_text = None; let declared = operation .query .filters @@ -1646,6 +1706,19 @@ fn prepare_list( return Err(ProblemCode::FieldsInvalid); } } + "profile" => { + if profile_text.replace(value).is_some() { + return Err(ProblemCode::UnsupportedRepresentation); + } + } + "bbox" => { + if operation.query.spatial_bbox.is_none() { + return Err(ProblemCode::UnknownFilter); + } + if bbox_text.replace(value).is_some() { + return Err(ProblemCode::InvalidFilter); + } + } "representation" => {} _ if declared.contains(name.as_str()) => { if raw_filters.insert(name, value).is_some() { @@ -1655,7 +1728,11 @@ fn prepare_list( _ => return Err(ProblemCode::UnknownFilter), } } - if raw_filters.is_empty() && !operation.query.allow_unfiltered { + let bbox = bbox_text + .as_deref() + .map(|value| parse_bbox(operation, value)) + .transpose()?; + if raw_filters.is_empty() && bbox.is_none() && !operation.query.allow_unfiltered { return Err(ProblemCode::InvalidFilter); } let mut filters = BTreeMap::new(); @@ -1683,29 +1760,44 @@ fn prepare_list( &access.representation, fields_text.as_deref(), )?; + let response_format = select_profile( + resource, + &access.representation, + negotiated, + profile_text.as_deref(), + )?; Ok(PreparedList { page_size, filters, selected_fields, after_order: None, + bbox, + response_format, }) } -fn selected_fields( +fn prepare_single_request( resource: &CompiledResource, operation: &CompiledOperation, representation: &CompiledRepresentation, + headers: &HeaderMap, query: Option<&str>, -) -> Result, ProblemCode> { +) -> Result<(ResponseFormat, Vec), ProblemCode> { + let negotiated = negotiate(headers, resource, representation)?; let parameters = parse_query(query)?; if parameters .iter() - .any(|(name, _)| name != "fields" && name != "representation") + .any(|(name, _)| name != "fields" && name != "profile" && name != "representation") { return Err(ProblemCode::ConsultationInvalidRequest); } let fields = one_parameter(¶meters, "fields")?; - fields_from_text(resource, operation, representation, fields) + let profile = one_parameter(¶meters, "profile") + .map_err(|_| ProblemCode::UnsupportedRepresentation)?; + Ok(( + select_profile(resource, representation, negotiated, profile)?, + fields_from_text(resource, operation, representation, fields)?, + )) } fn fields_from_text( @@ -1734,10 +1826,14 @@ fn fields_from_text( .collect::>(); if requested.iter().any(|field| { !allowed.contains(field) - || !resource + || !(resource .properties .iter() .any(|property| property.name == **field) + || resource + .primary_geometry + .as_ref() + .is_some_and(|geometry| geometry.name == **field)) }) { return Err(ProblemCode::FieldsInvalid); } @@ -1770,6 +1866,7 @@ fn validate_selected_inventory( fn validate_filter_inventory( operation: &CompiledOperation, filters: &BTreeMap, + bbox_present: bool, ) -> Result<(), ProblemCode> { let declared = operation .query @@ -1778,13 +1875,135 @@ fn validate_filter_inventory( .map(|filter| filter.parameter.as_str()) .collect::>(); if filters.keys().any(|name| !declared.contains(name.as_str())) - || (filters.is_empty() && !operation.query.allow_unfiltered) + || (filters.is_empty() && !bbox_present && !operation.query.allow_unfiltered) { return Err(ProblemCode::CursorInvalid); } Ok(()) } +fn select_profile( + resource: &CompiledResource, + representation: &CompiledRepresentation, + negotiated: ResponseFormat, + requested: Option<&str>, +) -> Result { + match negotiated { + ResponseFormat::Json | ResponseFormat::JsonLd => { + if requested.is_some() { + return Err(ProblemCode::UnsupportedRepresentation); + } + Ok(negotiated) + } + ResponseFormat::GeoJson(_) => { + let profile = match requested.unwrap_or("rfc7946") { + "rfc7946" => GeoJsonProfile::Rfc7946, + "jsonfg" => GeoJsonProfile::JsonFg, + _ => return Err(ProblemCode::UnsupportedRepresentation), + }; + if supports_geojson(resource, representation) { + Ok(ResponseFormat::GeoJson(profile)) + } else { + Err(ProblemCode::UnsupportedRepresentation) + } + } + } +} + +fn response_format_from_cursor( + resource: &CompiledResource, + representation: &CompiledRepresentation, + payload: &CursorPayload, +) -> Result { + match ( + payload.response_format.as_str(), + payload.response_profile.as_deref(), + ) { + ("json", None) => Ok(ResponseFormat::Json), + ("json-ld", None) => Ok(ResponseFormat::JsonLd), + ("geojson", Some(profile)) => select_profile( + resource, + representation, + ResponseFormat::GeoJson(GeoJsonProfile::Rfc7946), + Some(profile), + ), + _ => Err(ProblemCode::CursorInvalid), + } +} + +fn supports_geojson(resource: &CompiledResource, representation: &CompiledRepresentation) -> bool { + resource.primary_geometry.as_ref().is_some_and(|geometry| { + representation + .selectable_properties + .iter() + .any(|property| property == &geometry.name) + }) +} + +fn parse_bbox(operation: &CompiledOperation, text: &str) -> Result { + if text.is_empty() || text.len() > 256 || text.chars().any(char::is_control) { + return Err(ProblemCode::InvalidFilter); + } + let values = text.split(',').map(str::to_owned).collect::>(); + let values: [String; 4] = values.try_into().map_err(|_| ProblemCode::InvalidFilter)?; + parse_bbox_values(operation, &values) +} + +fn parse_bbox_values( + operation: &CompiledOperation, + values: &[String; 4], +) -> Result { + let spatial = operation + .query + .spatial_bbox + .as_ref() + .ok_or(ProblemCode::InvalidFilter)?; + let mut coordinates = [0.0; 4]; + for (target, value) in coordinates.iter_mut().zip(values) { + *target = value + .parse::() + .ok() + .filter(|value| value.is_finite()) + .ok_or(ProblemCode::InvalidFilter)?; + if *target == 0.0 { + *target = 0.0; + } + } + let bbox = PointBbox { + west: coordinates[0], + south: coordinates[1], + east: coordinates[2], + north: coordinates[3], + }; + if !(-180.0..=180.0).contains(&bbox.west) + || !(-180.0..=180.0).contains(&bbox.east) + || !(-90.0..=90.0).contains(&bbox.south) + || !(-90.0..=90.0).contains(&bbox.north) + || bbox.west > bbox.east + || bbox.south > bbox.north + { + return Err(ProblemCode::InvalidFilter); + } + let longitude_span = bbox.east - bbox.west; + let latitude_span = bbox.north - bbox.south; + if longitude_span > f64::from(spatial.maximum_longitude_span_degrees) + || latitude_span > f64::from(spatial.maximum_latitude_span_degrees) + { + return Err(ProblemCode::InvalidFilter); + } + Ok(bbox) +} + +fn canonical_bbox(bbox: PointBbox) -> [String; 4] { + [bbox.west, bbox.south, bbox.east, bbox.north].map(|value| { + if value == 0.0 { + "0".to_owned() + } else { + value.to_string() + } + }) +} + fn parse_query(query: Option<&str>) -> Result, ProblemCode> { let Some(query) = query else { return Ok(Vec::new()); @@ -1987,14 +2206,13 @@ fn record_value( let properties = representation .selectable_properties .iter() - .map(|name| { + .filter_map(|name| { resource .properties .iter() .find(|property| property.name == *name) - .ok_or(RecordError::InvalidSource) }) - .collect::, _>>()?; + .collect::>(); let mut transformed = BTreeMap::new(); for property in &properties { let source = row @@ -2028,6 +2246,18 @@ fn record_value( } transformed.insert(property.name.as_str(), value); } + let selected_geometry = resource.primary_geometry.as_ref().filter(|geometry| { + representation + .selectable_properties + .iter() + .any(|property| property == &geometry.name) + }); + let geometry = match selected_geometry { + Some(definition) => { + validated_geometry(row, definition).ok_or(RecordError::InvalidSource)? + } + None => None, + }; let mut domain = Map::new(); for property in properties { if !selected.contains(&property.name) { @@ -2040,6 +2270,11 @@ fn record_value( ); } } + if let (Some(definition), Some(value)) = (selected_geometry, geometry) { + if selected.contains(&definition.name) { + domain.insert(definition.name.clone(), value); + } + } Ok(json!({ "registryIdentifier": service.registry.registry_identifier, "recordIdentifier": record_identifier, @@ -2053,6 +2288,38 @@ fn record_value( })) } +fn coordinate(value: &SqlValue) -> Option { + match value { + SqlValue::Integer(value) => Some(*value as f64), + SqlValue::Number(value) if value.is_finite() => Some(*value), + SqlValue::Null | SqlValue::String(_) | SqlValue::Boolean(_) | SqlValue::Number(_) => None, + } +} + +fn validated_geometry( + row: &ResultRow, + geometry: &crate::model::CompiledPrimaryGeometry, +) -> Option> { + let longitude = row.get(&geometry.longitude_column)?; + let latitude = row.get(&geometry.latitude_column)?; + match (longitude, latitude) { + (SqlValue::Null, SqlValue::Null) if !geometry.source_required => Some(None), + (SqlValue::Null, SqlValue::Null) => None, + (SqlValue::Null, _) | (_, SqlValue::Null) => None, + (longitude, latitude) => { + let longitude = coordinate(longitude)?; + let latitude = coordinate(latitude)?; + if !(-180.0..=180.0).contains(&longitude) || !(-90.0..=90.0).contains(&latitude) { + return None; + } + Some(Some(json!({ + "type": "Point", + "coordinates": [longitude, latitude], + }))) + } + } +} + fn valid_property_value( service: &RelayService, value: &SqlValue, @@ -2119,7 +2386,7 @@ fn record_meta( selected: &[String], source_revision: &SourceRevision, ) -> Value { - let pattern = operation_pattern(&operation.kind); + let pattern = operation_pattern(operation.pattern); json!({ "operationIdentifier": operation.identifier, "representation": representation.id, @@ -2149,6 +2416,85 @@ fn source_revision_value(source: &SourceRevision) -> Value { } } +fn geojson_collection( + service: &RelayService, + resource: &CompiledResource, + records: Vec, + next_cursor: Option, + meta: Value, + profile: GeoJsonProfile, +) -> Value { + let features = records + .into_iter() + .map(|record| geojson_feature(service, resource, record, None, profile, false)) + .collect::>(); + let mut document = json!({ + "type": "FeatureCollection", + "features": features, + "pageInfo": {"nextCursor": next_cursor}, + "meta": meta, + }); + if profile == GeoJsonProfile::JsonFg { + add_json_fg_members(&mut document, resource); + } + document +} + +fn geojson_feature( + service: &RelayService, + resource: &CompiledResource, + mut record: Value, + meta: Option, + profile: GeoJsonProfile, + root: bool, +) -> Value { + let identifier = record + .get("recordIdentifier") + .and_then(Value::as_str) + .unwrap_or_default() + .to_owned(); + let geometry = resource + .primary_geometry + .as_ref() + .and_then(|definition| { + record + .get_mut("domainData") + .and_then(Value::as_object_mut) + .and_then(|domain| domain.remove(&definition.name)) + }) + .unwrap_or(Value::Null); + let mut feature = json!({ + "type": "Feature", + "id": absolute( + &service.registry.base_uri, + &format!("/v2/resources/{}/records/{identifier}", resource.id), + ), + "geometry": geometry, + "properties": record, + }); + if let Some(meta) = meta { + feature + .as_object_mut() + .expect("feature is an object") + .insert("meta".into(), meta); + } + if root && profile == GeoJsonProfile::JsonFg { + add_json_fg_members(&mut feature, resource); + } + feature +} + +fn add_json_fg_members(document: &mut Value, resource: &CompiledResource) { + let Some(object) = document.as_object_mut() else { + return; + }; + object.insert( + "conformsTo".into(), + json!([JSON_FG_CORE_CONFORMANCE, JSON_FG_TYPES_SCHEMAS_CONFORMANCE,]), + ); + object.insert("featureType".into(), Value::String(resource.id.clone())); +} + fn apply_json_ld( service: &RelayService, resource: &CompiledResource, @@ -2231,7 +2577,9 @@ async fn release_document( { return ProblemCode::AuditUnavailable.response(trace); } - return not_modified(etag.as_deref().unwrap_or_default(), trace); + let mut response = not_modified(etag.as_deref().unwrap_or_default(), trace); + apply_profile_link(&mut response, representation); + return response; } if service .audit @@ -2241,13 +2589,24 @@ async fn release_document( { return ProblemCode::AuditUnavailable.response(trace); } - bytes_response( + let mut response = bytes_response( bytes, representation.media_type(), cacheable, etag.as_deref(), trace, - ) + ); + apply_profile_link(&mut response, representation); + response +} + +fn apply_profile_link(response: &mut Response, representation: ResponseFormat) { + let Some(uri) = representation.profile_link() else { + return; + }; + if let Ok(value) = HeaderValue::from_str(&format!("<{uri}>; rel=\"profile\"")) { + response.headers_mut().insert(LINK, value); + } } struct BoundedWriter { @@ -2338,7 +2697,11 @@ fn cacheable(representation: &CompiledRepresentation, source: &SourceRevision) - && matches!(source, SourceRevision::Snapshot(_)) } -fn negotiate(headers: &HeaderMap) -> Result { +fn negotiate( + headers: &HeaderMap, + resource: &CompiledResource, + representation: &CompiledRepresentation, +) -> Result { let Some(value) = headers.get(ACCEPT) else { return Ok(ResponseFormat::Json); }; @@ -2347,6 +2710,7 @@ fn negotiate(headers: &HeaderMap) -> Result { .map_err(|_| ProblemCode::UnsupportedRepresentation)?; let mut json = false; let mut json_ld = false; + let mut geojson = false; for item in value.split(',') { let mut parts = item.trim().split(';'); let media = parts.next().unwrap_or_default().trim(); @@ -2357,11 +2721,14 @@ fn negotiate(headers: &HeaderMap) -> Result { match media { "application/json" | "application/*" | "*/*" => json = true, "application/ld+json" => json_ld = true, + "application/geo+json" => geojson = true, _ => {} } } if json_ld { Ok(ResponseFormat::JsonLd) + } else if geojson && supports_geojson(resource, representation) { + Ok(ResponseFormat::GeoJson(GeoJsonProfile::Rfc7946)) } else if json { Ok(ResponseFormat::Json) } else { @@ -2410,9 +2777,13 @@ fn next_cursor( service, operation, access, - &query.filters, - &query.selected_fields, - &source_revision.cursor_value(), + CursorQueryContext { + filters: &query.filters, + selected_fields: &query.selected_fields, + source_revision: &source_revision.cursor_value(), + bbox: query.bbox, + response_format: query.response_format, + }, ) .map_err(|_| ())?; payload.expires_at_unix_seconds = now_unix_seconds() @@ -2440,16 +2811,16 @@ fn cursor_template( service: &RelayService, operation: &CompiledOperation, access: &Access, - filters: &BTreeMap, - selected_fields: &[String], - source_revision: &str, + context: CursorQueryContext<'_>, ) -> Result { let key = service .cursor_key .as_ref() .ok_or(ProblemCode::CursorInvalid)?; - let filter_json = serde_json::to_vec(filters).map_err(|_| ProblemCode::CursorInvalid)?; - let field_json = serde_json::to_vec(selected_fields).map_err(|_| ProblemCode::CursorInvalid)?; + let filter_json = + serde_json::to_vec(context.filters).map_err(|_| ProblemCode::CursorInvalid)?; + let field_json = + serde_json::to_vec(context.selected_fields).map_err(|_| ProblemCode::CursorInvalid)?; let order_json = serde_json::to_vec(&operation.query.order_by).map_err(|_| ProblemCode::CursorInvalid)?; let transform_json = serde_json::to_vec(&access.representation.transform_inventory) @@ -2464,7 +2835,7 @@ fn cursor_template( Ok(CursorPayload::new( u64::MAX, service.registry.contract_revision.clone(), - source_revision.to_owned(), + context.source_revision.to_owned(), operation.identifier.clone(), CursorBindings { representation: access.representation.id.clone(), @@ -2486,6 +2857,11 @@ fn cursor_template( .map_err(|_| ProblemCode::CursorInvalid)?, last_record_identifier: String::new(), }, + ) + .with_response_context( + context.bbox.map(canonical_bbox), + context.response_format.cursor_kind().to_owned(), + context.response_format.cursor_profile().map(str::to_owned), )) } @@ -2724,7 +3100,7 @@ fn capability( ) -> Value { let mut document = json!({ "family": "consultation", - "pattern": operation_pattern(&operation.kind), + "pattern": operation_pattern(operation.pattern), "resourceIdentifier": resource.id, "operationIdentifier": operation.identifier, "representation": representation.id, @@ -2747,6 +3123,23 @@ fn capability( let object = document .as_object_mut() .expect("capability document is an object"); + object.insert( + "formats".into(), + Value::Array(response_format_documents(resource, representation)), + ); + if let Some(spatial) = &operation.query.spatial_bbox { + object.insert( + "spatialQuery".into(), + json!({ + "bbox": { + "crs": CRS84_URI, + "predicate": "exact-point-intersection", + "maximumLongitudeSpanDegrees": spatial.maximum_longitude_span_degrees, + "maximumLatitudeSpanDegrees": spatial.maximum_latitude_span_degrees, + } + }), + ); + } if service.registry.metadata_visibility.classifications != Visibility::OperatorOnly { object.insert( "classificationReference".into(), @@ -2768,6 +3161,46 @@ fn capability( document } +fn response_format_documents( + resource: &CompiledResource, + representation: &CompiledRepresentation, +) -> Vec { + let mut formats = vec![ + json!({"id": "json", "mediaType": "application/json", "profiles": []}), + json!({"id": "json-ld", "mediaType": "application/ld+json", "profiles": []}), + ]; + if supports_geojson(resource, representation) { + formats.push(json!({ + "id": "geojson", + "mediaType": "application/geo+json", + "profiles": [ + representation_profile_document(RepresentationProfile::Rfc7946), + representation_profile_document(RepresentationProfile::JsonFg), + ], + })); + } + formats +} + +fn representation_profile_document(profile: RepresentationProfile) -> Value { + match profile { + RepresentationProfile::Rfc7946 => json!({ + "id": "rfc7946", + "uri": RFC_7946_PROFILE_URI, + "crs": CRS84_URI, + }), + RepresentationProfile::JsonFg => json!({ + "id": "jsonfg", + "uri": JSON_FG_PROFILE_URI, + "crs": CRS84_URI, + "conformsTo": [ + JSON_FG_CORE_CONFORMANCE, + JSON_FG_TYPES_SCHEMAS_CONFORMANCE, + ], + }), + } +} + fn sibling_artifact_reference(reference: &str, artifact_identifier: &str) -> String { reference.rsplit_once("/v2/artifacts/").map_or_else( || format!("/v2/artifacts/{artifact_identifier}"), @@ -2783,11 +3216,11 @@ fn operation_artifact_stem(resource: &str, kind: &OperationKind) -> String { } } -fn operation_pattern(kind: &OperationKind) -> &'static str { - match kind { - OperationKind::List => "list", - OperationKind::Read => "retrieve", - OperationKind::Lookup { .. } => "search", +fn operation_pattern(pattern: ConsultationPattern) -> &'static str { + match pattern { + ConsultationPattern::List => "list", + ConsultationPattern::Retrieve => "retrieve", + ConsultationPattern::Search => "search", } } diff --git a/crates/registry-relay-v2/src/artifacts.rs b/crates/registry-relay-v2/src/artifacts.rs index 9d9e1a3f1..c0d878448 100644 --- a/crates/registry-relay-v2/src/artifacts.rs +++ b/crates/registry-relay-v2/src/artifacts.rs @@ -10,12 +10,22 @@ use sha2::{Digest, Sha256}; use thiserror::Error; use crate::contract::Visibility; -use crate::model::{CompiledAccess, CompiledRegistry, OperationKind}; +use crate::model::{ + CompiledAccess, CompiledOperation, CompiledRegistry, CompiledResource, ConsultationPattern, + OperationKind, RepresentationProfile, +}; use crate::semantics::{ full_record_schema, full_record_shacl, json_ld_context, local_vocabulary, representation_schema, representation_shacl, }; +const CRS84_URI: &str = "http://www.opengis.net/def/crs/OGC/0/CRS84"; +const RFC7946_PROFILE_URI: &str = "http://www.opengis.net/def/profile/OGC/0/rfc7946"; +const JSON_FG_PROFILE_URI: &str = "http://www.opengis.net/def/profile/OGC/0/jsonfg"; +const JSON_FG_CORE_CONFORMANCE: &str = "http://www.opengis.net/spec/json-fg-1/1.0/conf/core"; +const JSON_FG_TYPES_CONFORMANCE: &str = + "http://www.opengis.net/spec/json-fg-1/1.0/conf/types-schemas"; + #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct ArtifactSet { @@ -123,6 +133,12 @@ pub fn generate_artifacts(registry: &CompiledRegistry) -> Result>(); push_json( &mut artifacts, @@ -166,7 +182,12 @@ pub fn generate_artifacts(registry: &CompiledRegistry) -> Result>(), + })).chain(resource.primary_geometry.iter().map(|geometry| json!({ + "property": geometry.name, + "classification": geometry.classification, + "geometryType": "Point", + "crs": geometry.crs, + }))).collect::>(), "columns": resource.column_accounting, }), )?; @@ -256,6 +277,24 @@ pub fn generate_artifacts(registry: &CompiledRegistry) -> Result Result>(), }), )?; @@ -578,21 +624,15 @@ fn openapi(registry: &CompiledRegistry, public_only: bool) -> Value { if visible_representations.is_empty() { continue; } - let (method, path, pattern) = match &operation.kind { - OperationKind::List => ( - "get", - format!("/v2/resources/{}/records", resource.id), - "list", - ), + let (method, path) = match &operation.kind { + OperationKind::List => ("get", format!("/v2/resources/{}/records", resource.id)), OperationKind::Read => ( "get", format!("/v2/resources/{}/records/{{recordIdentifier}}", resource.id), - "retrieve", ), OperationKind::Lookup { name } => ( "post", format!("/v2/resources/{}/lookups/{name}", resource.id), - "search", ), }; let has_public = visible_representations @@ -640,6 +680,18 @@ fn openapi(registry: &CompiledRegistry, public_only: bool) -> Value { "description": "Duplicate-free comma-separated subset of the selected representation" }), ]; + let has_geojson = visible_representations + .iter() + .any(|representation| supports_geojson(resource, representation)); + if has_geojson { + parameters.push(json!({ + "name": "profile", + "in": "query", + "required": false, + "schema": {"type": "string", "enum": ["rfc7946", "jsonfg"], "default": "rfc7946"}, + "description": "GeoJSON profile. Valid only with Accept: application/geo+json." + })); + } match &operation.kind { OperationKind::List => { let pagination = operation @@ -658,6 +710,26 @@ fn openapi(registry: &CompiledRegistry, public_only: bool) -> Value { "x-registry-exact-equality": true, })); } + if let Some(bbox) = &operation.query.spatial_bbox { + parameters.push(json!({ + "name": "bbox", + "in": "query", + "required": false, + "style": "form", + "explode": false, + "schema": { + "type": "array", + "items": {"type": "number"}, + "minItems": 4, + "maxItems": 4 + }, + "description": "Inclusive CRS84 point bounds: west,south,east,north", + "x-registry-spatial-predicate": "exact-point-intersection", + "x-registry-crs": CRS84_URI, + "x-registry-maximum-longitude-span-degrees": bbox.maximum_longitude_span_degrees, + "x-registry-maximum-latitude-span-degrees": bbox.maximum_latitude_span_degrees, + })); + } } OperationKind::Read => parameters.push(json!({ "name": "recordIdentifier", "in": "path", "required": true, @@ -665,10 +737,33 @@ fn openapi(registry: &CompiledRegistry, public_only: bool) -> Value { })), OperationKind::Lookup { .. } => {} } + let mut success_response = json!({ + "description": "A validated minimum-disclosure Registry response", + "content": operation_response_content( + registry, + operation, + resource, + &visible_representations, + ) + }); + if has_geojson { + success_response + .as_object_mut() + .expect("response object") + .insert( + "headers".into(), + json!({ + "Link": { + "description": "Selected RFC 7946 or JSON-FG profile link for GeoJSON responses", + "schema": {"type": "string"} + } + }), + ); + } let mut operation_value = json!({ "operationId": operation.identifier, "x-registry-family": "consultation", - "x-registry-pattern": pattern, + "x-registry-pattern": consultation_pattern(operation.pattern), "x-registry-representations": visible_representations.iter().map(|representation| json!({ "identifier": representation.id, "default": operation.default_representation == representation.id, @@ -679,17 +774,12 @@ fn openapi(registry: &CompiledRegistry, public_only: bool) -> Value { "schemaReference": representation.schema_reference, "semanticModelReference": representation.semantic_model_reference, "contextReference": representation.context_reference, + "formats": response_format_documents(resource, representation), })).collect::>(), "security": security, "parameters": parameters, "responses": { - "200": { - "description": "A validated minimum-disclosure Registry response", - "content": { - "application/json": {"schema": operation_response_schema(operation, &visible_representations)}, - "application/ld+json": {"schema": operation_response_schema(operation, &visible_representations)} - } - }, + "200": success_response, "default": {"$ref": "#/components/responses/Problem"} } }); @@ -836,6 +926,274 @@ fn operation_response_schema( } } +fn operation_response_content( + registry: &CompiledRegistry, + operation: &CompiledOperation, + resource: &CompiledResource, + representations: &[&crate::model::CompiledRepresentation], +) -> Value { + let ordinary = operation_response_schema(operation, representations); + let mut content = Map::from_iter([ + ( + "application/json".into(), + json!({"schema": ordinary.clone()}), + ), + ("application/ld+json".into(), json!({"schema": ordinary})), + ]); + let spatial = representations + .iter() + .filter(|representation| supports_geojson(resource, representation)) + .map(|representation| { + geojson_response_schema(registry, operation, representation, resource, false) + }) + .collect::>(); + if !spatial.is_empty() { + let schema = if spatial.len() == 1 { + spatial.into_iter().next().expect("one spatial schema") + } else { + json!({"oneOf": spatial}) + }; + content.insert("application/geo+json".into(), json!({"schema": schema})); + } + Value::Object(content) +} + +fn geojson_response_schema( + registry: &CompiledRegistry, + operation: &CompiledOperation, + representation: &crate::model::CompiledRepresentation, + resource: &CompiledResource, + include_identity: bool, +) -> Value { + let mut schema = match &operation.kind { + OperationKind::List => json!({ + "type": "object", + "additionalProperties": false, + "required": ["type", "features", "pageInfo", "meta"], + "properties": { + "type": {"type": "string", "enum": ["FeatureCollection"]}, + "features": { + "type": "array", + "items": geojson_feature_schema(registry, representation, resource, false) + }, + "pageInfo": { + "type": "object", + "additionalProperties": false, + "required": ["nextCursor"], + "properties": {"nextCursor": {"type": ["string", "null"]}} + }, + "meta": {"type": "object"}, + "conformsTo": json_fg_conforms_to_schema(), + "featureType": {"type": "string", "enum": [resource.id]} + }, + "dependentRequired": { + "conformsTo": ["featureType"], + "featureType": ["conformsTo"] + } + }), + OperationKind::Read | OperationKind::Lookup { .. } => { + geojson_feature_schema(registry, representation, resource, true) + } + }; + if include_identity { + schema + .as_object_mut() + .expect("GeoJSON schema object") + .insert( + "$schema".into(), + json!("https://json-schema.org/draft/2020-12/schema"), + ); + schema + .as_object_mut() + .expect("GeoJSON schema object") + .insert( + "$id".into(), + json!(representation + .schema_reference + .strip_suffix("-schema") + .map(|base| format!("{base}-geojson-schema")) + .unwrap_or_else(|| format!("{}-geojson", representation.schema_reference))), + ); + } + schema +} + +fn geojson_feature_schema( + registry: &CompiledRegistry, + representation: &crate::model::CompiledRepresentation, + resource: &CompiledResource, + require_meta: bool, +) -> Value { + let mut required = vec![ + json!("type"), + json!("id"), + json!("geometry"), + json!("properties"), + ]; + if require_meta { + required.push(json!("meta")); + } + let mut properties = json!({ + "type": {"type": "string", "enum": ["Feature"]}, + "id": {"type": "string", "minLength": 1}, + "geometry": { + "oneOf": [point_geometry_schema(), {"type": "null"}] + }, + "properties": geojson_record_properties_schema(registry, representation, resource) + }); + if require_meta { + let properties = properties + .as_object_mut() + .expect("Feature properties schema is an object"); + properties.insert("meta".into(), json!({"type": "object"})); + properties.insert("conformsTo".into(), json_fg_conforms_to_schema()); + properties.insert( + "featureType".into(), + json!({"type": "string", "enum": [resource.id]}), + ); + } + let mut schema = json!({ + "type": "object", + "additionalProperties": false, + "required": required, + "properties": properties + }); + if require_meta { + schema + .as_object_mut() + .expect("Feature schema is an object") + .insert( + "dependentRequired".into(), + json!({ + "conformsTo": ["featureType"], + "featureType": ["conformsTo"] + }), + ); + } + schema +} + +fn geojson_record_properties_schema( + registry: &CompiledRegistry, + representation: &crate::model::CompiledRepresentation, + resource: &CompiledResource, +) -> Value { + let geometry_name = resource + .primary_geometry + .as_ref() + .map(|geometry| geometry.name.as_str()); + let selected = representation + .selectable_properties + .iter() + .filter(|property| Some(property.as_str()) != geometry_name) + .cloned() + .collect::>(); + let mut schema = representation_schema( + registry, + resource, + &selected, + &representation.schema_reference, + &representation.semantic_model_reference, + ); + let object = schema + .as_object_mut() + .expect("Registry Record schema is an object"); + object.remove("$schema"); + object.remove("$id"); + schema +} + +fn point_geometry_schema() -> Value { + json!({ + "type": "object", + "additionalProperties": false, + "required": ["type", "coordinates"], + "properties": { + "type": {"type": "string", "enum": ["Point"]}, + "coordinates": { + "type": "array", + "prefixItems": [ + {"type": "number", "minimum": -180, "maximum": 180}, + {"type": "number", "minimum": -90, "maximum": 90} + ], + "items": false, + "minItems": 2, + "maxItems": 2 + } + } + }) +} + +fn json_fg_conforms_to_schema() -> Value { + json!({ + "type": "array", + "items": { + "type": "string", + "enum": [JSON_FG_CORE_CONFORMANCE, JSON_FG_TYPES_CONFORMANCE] + }, + "minItems": 2, + "maxItems": 2, + "uniqueItems": true + }) +} + +fn consultation_pattern(pattern: ConsultationPattern) -> &'static str { + match pattern { + ConsultationPattern::List => "list", + ConsultationPattern::Retrieve => "retrieve", + ConsultationPattern::Search => "search", + } +} + +fn supports_geojson( + resource: &CompiledResource, + representation: &crate::model::CompiledRepresentation, +) -> bool { + resource.primary_geometry.as_ref().is_some_and(|geometry| { + representation + .selectable_properties + .iter() + .any(|property| property == &geometry.name) + }) +} + +fn response_format_documents( + resource: &CompiledResource, + representation: &crate::model::CompiledRepresentation, +) -> Vec { + let mut formats = vec![ + json!({"id": "json", "mediaType": "application/json", "profiles": []}), + json!({"id": "json-ld", "mediaType": "application/ld+json", "profiles": []}), + ]; + if supports_geojson(resource, representation) { + formats.push(json!({ + "id": "geojson", + "mediaType": "application/geo+json", + "profiles": [ + representation_profile(RepresentationProfile::Rfc7946), + representation_profile(RepresentationProfile::JsonFg), + ], + })); + } + formats +} + +fn representation_profile(profile: RepresentationProfile) -> Value { + match profile { + RepresentationProfile::Rfc7946 => json!({ + "id": "rfc7946", + "uri": RFC7946_PROFILE_URI, + "crs": CRS84_URI, + }), + RepresentationProfile::JsonFg => json!({ + "id": "jsonfg", + "uri": JSON_FG_PROFILE_URI, + "crs": CRS84_URI, + "conformsTo": [JSON_FG_CORE_CONFORMANCE, JSON_FG_TYPES_CONFORMANCE], + }), + } +} + fn openapi_type(data_type: crate::contract::DataType) -> Value { use crate::contract::DataType; match data_type { @@ -906,6 +1264,15 @@ fn capability_inventory( "schemaReference": representation.schema_reference, "semanticModelReference": representation.semantic_model_reference, "contextReference": representation.context_reference, + "formats": response_format_documents(resource, representation), + "spatialQuery": operation.query.spatial_bbox.as_ref().map(|spatial| json!({ + "bbox": { + "crs": CRS84_URI, + "predicate": "exact-point-intersection", + "maximumLongitudeSpanDegrees": spatial.maximum_longitude_span_degrees, + "maximumLatitudeSpanDegrees": spatial.maximum_latitude_span_degrees, + } + })), })) }) }) @@ -979,7 +1346,7 @@ mod tests { use super::*; use crate::compiler::{compile_contract_with_governed_files, tests as compiler_tests}; use crate::contract::RegistryContract; - use crate::model::CompileProfile; + use crate::model::{CompileProfile, CompiledRegistry}; #[test] fn generated_inventory_covers_required_v1_artifact_classes_only() { @@ -1167,4 +1534,182 @@ mod tests { ); } } + + #[test] + fn spatial_artifacts_are_deterministic_bounded_and_carrier_free() { + let registry = spatial_registry(CompiledAccess::Public); + let generated = generate_artifacts(®istry).expect("spatial artifacts generate"); + assert_eq!( + generated, + generate_artifacts(®istry).expect("repeat generation") + ); + + let geojson_schema = generated + .get("artifacts/record--list--representation-public.geojson.schema.json") + .expect("GeoJSON wrapper schema"); + let schema: Value = serde_json::from_slice(&geojson_schema.content).expect("schema JSON"); + assert_eq!(schema["properties"]["type"]["enum"][0], "FeatureCollection"); + assert_eq!( + schema["properties"]["features"]["items"]["properties"]["geometry"]["oneOf"][0] + ["properties"]["type"]["enum"][0], + "Point" + ); + let coordinates = &schema["properties"]["features"]["items"]["properties"]["geometry"] + ["oneOf"][0]["properties"]["coordinates"]; + assert_eq!(coordinates["prefixItems"][0]["minimum"], -180); + assert_eq!(coordinates["prefixItems"][0]["maximum"], 180); + assert_eq!(coordinates["prefixItems"][1]["minimum"], -90); + assert_eq!(coordinates["prefixItems"][1]["maximum"], 90); + assert_eq!(coordinates["items"], false); + + let validator = jsonschema::JSONSchema::options() + .with_draft(jsonschema::Draft::Draft202012) + .compile(&schema) + .expect("generated GeoJSON schema compiles"); + let resource = ®istry.resources[0]; + let operation = &resource.operations[0]; + let representation = &operation.representations[0]; + let lifecycle = registry.codelists[0].values[0].clone(); + let point = json!({"type": "Point", "coordinates": [100.0, 13.0]}); + let record = json!({ + "registryIdentifier": registry.registry_identifier, + "recordIdentifier": "record-1", + "revisionIdentifier": "revision-1", + "lifecycleState": lifecycle, + "schemaReference": representation.schema_reference, + "semanticModelReference": representation.semantic_model_reference, + "authorityIdentifier": registry.authority_identifier, + "recordedAt": "2026-08-10T00:00:00Z", + "domainData": {"name": "Example"} + }); + let feature = json!({ + "type": "Feature", + "id": "https://example.invalid/records/record-1", + "geometry": point, + "properties": record + }); + let rfc_response = json!({ + "type": "FeatureCollection", + "features": [feature], + "pageInfo": {"nextCursor": null}, + "meta": {} + }); + assert!(validator.is_valid(&rfc_response)); + + let mut json_fg_response = rfc_response.clone(); + json_fg_response["conformsTo"] = + json!([JSON_FG_CORE_CONFORMANCE, JSON_FG_TYPES_CONFORMANCE]); + json_fg_response["featureType"] = json!(resource.id); + assert!(validator.is_valid(&json_fg_response)); + + let mut nested_json_fg_metadata = json_fg_response.clone(); + nested_json_fg_metadata["features"][0]["conformsTo"] = + json!([JSON_FG_CORE_CONFORMANCE, JSON_FG_TYPES_CONFORMANCE]); + nested_json_fg_metadata["features"][0]["featureType"] = json!(resource.id); + assert!( + !validator.is_valid(&nested_json_fg_metadata), + "JSON-FG conformance metadata is permitted only on the root object" + ); + + let mut duplicate_geometry = rfc_response; + duplicate_geometry["features"][0]["properties"]["domainData"]["location"] = + json!({"type": "Point", "coordinates": [101.0, 14.0]}); + assert!( + !validator.is_valid(&duplicate_geometry), + "Feature geometry cannot be repeated or contradicted in properties.domainData" + ); + + let openapi: Value = serde_json::from_slice( + &generated + .get("openapi.public.json") + .expect("public OpenAPI") + .content, + ) + .expect("OpenAPI JSON"); + serde_json::from_value::(openapi.clone()) + .expect("spatial OpenAPI conforms to the maintained OpenAPI model"); + let operation = &openapi["paths"]["/v2/resources/record/records"]["get"]; + assert_eq!(operation["x-registry-pattern"], "search"); + assert!(operation["responses"]["200"]["content"] + .get("application/geo+json") + .is_some()); + let bbox = operation["parameters"] + .as_array() + .expect("parameters") + .iter() + .find(|parameter| parameter["name"] == "bbox") + .expect("bbox parameter"); + assert_eq!(bbox["schema"]["minItems"], 4); + assert_eq!(bbox["explode"], false); + + let capabilities = generated + .get("artifacts/capabilities.json") + .expect("public capabilities"); + let encoded = String::from_utf8(capabilities.content.clone()).expect("UTF-8 capability"); + assert!(encoded.contains("exact-point-intersection")); + assert!(encoded.contains(JSON_FG_PROFILE_URI)); + assert!(!encoded.contains("longitude_col")); + assert!(!encoded.contains("latitude_col")); + assert!(!encoded.to_ascii_lowercase().contains("spatialite")); + assert!(!encoded.to_ascii_lowercase().contains("geopackage")); + assert!(!encoded.contains("ogcapi-features")); + } + + #[test] + fn public_projection_does_not_reveal_protected_spatial_capability() { + let registry = spatial_registry(CompiledAccess::Protected { + scope: "registry:spatial:read".into(), + purpose: None, + row_binding: None, + }); + let generated = generate_artifacts(®istry).expect("spatial artifacts generate"); + let public_openapi = String::from_utf8( + generated + .get("openapi.public.json") + .expect("public OpenAPI") + .content + .clone(), + ) + .expect("UTF-8 OpenAPI"); + let public_capabilities = String::from_utf8( + generated + .get("artifacts/capabilities.json") + .expect("public capabilities") + .content + .clone(), + ) + .expect("UTF-8 capabilities"); + assert!(!public_openapi.contains("application/geo+json")); + assert!(!public_openapi.contains("exact-point-intersection")); + assert!(!public_capabilities.contains("application/geo+json")); + assert!(!public_capabilities.contains("exact-point-intersection")); + + let operation_capability = generated + .get("artifacts/record--list--representation-public.capability.json") + .expect("operation-bound capability"); + assert_eq!(operation_capability.visibility, Visibility::OperationBound); + assert_eq!( + operation_capability.operation_identifier.as_deref(), + Some("record.list") + ); + let encoded = String::from_utf8(operation_capability.content.clone()) + .expect("UTF-8 operation capability"); + assert!(encoded.contains("application/geo+json")); + assert!(!encoded.contains("longitude_col")); + assert!(!encoded.contains("latitude_col")); + } + + fn spatial_registry(access: CompiledAccess) -> CompiledRegistry { + let contract = compiler_tests::spatial_contract(true); + let governed_files = compiler_tests::governed_files_for(&contract); + let mut registry = compile_contract_with_governed_files( + &contract, + &[compiler_tests::spatial_observed_schema()], + CompileProfile::Production, + &governed_files, + ) + .expect("contract compiles"); + registry.resources[0].operations[0].representations[0].access = access; + registry + } } diff --git a/crates/registry-relay-v2/src/compiler.rs b/crates/registry-relay-v2/src/compiler.rs index 18a4786a9..cb4084219 100644 --- a/crates/registry-relay-v2/src/compiler.rs +++ b/crates/registry-relay-v2/src/compiler.rs @@ -18,11 +18,12 @@ use crate::model::{ CapabilityFamily, ColumnAccount, ColumnUse, CompileProfile, CompileReport, CompiledAccess, CompiledClassificationReview, CompiledCodelist, CompiledDisclosureProfile, CompiledFilter, CompiledGeneratedIdentificationBinding, CompiledGovernedFile, CompiledMetadataVisibility, - CompiledOperation, CompiledPagination, CompiledProperty, CompiledPurpose, - CompiledRecordContext, CompiledRegistry, CompiledRepresentation, CompiledResource, - CompiledRowBinding, CompiledSelector, CompiledSource, CompiledTransform, ConsultationPattern, - Diagnostic, DiagnosticSeverity, EffectiveClassification, ObservedSourceSchema, OperationKind, - QueryPlan, RowAuthoritySource, StarterColumn, StarterContract, + CompiledOperation, CompiledPagination, CompiledPrimaryGeometry, CompiledProperty, + CompiledPurpose, CompiledRecordContext, CompiledRegistry, CompiledRepresentation, + CompiledResource, CompiledRowBinding, CompiledSelector, CompiledSource, + CompiledSpatialBboxQuery, CompiledTransform, ConsultationPattern, Diagnostic, + DiagnosticSeverity, EffectiveClassification, ObservedSourceSchema, OperationKind, QueryPlan, + RowAuthoritySource, StarterColumn, StarterContract, }; const API_VERSION: &str = "relay.registrystack.org/v2alpha1"; @@ -39,6 +40,7 @@ const MAXIMUM_LOOKUP_REQUEST_BODY_BYTES: u32 = 1024 * 1024; const MAXIMUM_LOOKUP_SELECTORS: usize = 32; const MAXIMUM_SELECTOR_BYTES: u32 = 4 * 1024; const MAXIMUM_PARTIAL_STRING_CHARACTERS: u16 = 64; +const CRS84: &str = "http://www.opengis.net/def/crs/OGC/0/CRS84"; pub type GovernedFileSet = BTreeMap>; @@ -646,11 +648,13 @@ impl<'a> Compiler<'a> { let mut compiled = Vec::with_capacity(self.contract.resources.len()); for (index, resource) in self.contract.resources.iter().enumerate() { let root = format!("resources[{index}]"); - if resource.properties.len() > MAXIMUM_PROPERTIES_PER_RESOURCE { + if resource.properties.len() + usize::from(resource.primary_geometry.is_some()) + > MAXIMUM_PROPERTIES_PER_RESOURCE + { self.error( "property.bound_exceeded", &format!("{root}.properties"), - "the governed property count exceeds the per-resource product ceiling", + "the governed scalar and geometry property count exceeds the per-resource product ceiling", ); } if resource.disclosure_profiles.len() > MAXIMUM_DISCLOSURE_PROFILES_PER_RESOURCE { @@ -861,6 +865,179 @@ impl<'a> Compiler<'a> { }); } + let primary_geometry = resource.primary_geometry.as_ref().and_then(|geometry| { + let location = format!("{root}.primaryGeometry"); + if !valid_camel_identifier(&geometry.name) { + self.error( + "geometry.name_invalid", + &format!("{location}.name"), + "the primary geometry name must be URL-safe camelCase", + ); + } + if property_names.contains(geometry.name.as_str()) { + self.error( + "geometry.name_collision", + &format!("{location}.name"), + "the primary geometry name must not collide with a scalar property", + ); + } + if geometry.label.trim().is_empty() || geometry.description.trim().is_empty() { + self.error( + "geometry.documentation_empty", + &location, + "a primary geometry requires a non-empty label and description", + ); + } + if geometry.crs != CRS84 { + self.error( + "geometry.crs_unsupported", + &format!("{location}.crs"), + "the initial spatial profile supports only OGC CRS84", + ); + } + let longitude_column = &geometry.source.longitude_column; + let latitude_column = &geometry.source.latitude_column; + for (column, field) in [ + (longitude_column, "longitudeColumn"), + (latitude_column, "latitudeColumn"), + ] { + if !valid_sql_identifier(column) { + self.error( + "geometry.column_invalid", + &format!("{location}.source.{field}"), + "geometry carrier columns must be simple SQLite identifiers", + ); + } + if !column_exists(observed_columns.as_ref(), column) { + self.error( + "geometry.column_unknown", + &format!("{location}.source.{field}"), + "a geometry carrier column is absent from the reviewed view", + ); + } + if property_columns.contains_key(column.as_str()) + || [ + &resource.record_context.record_identifier.source_column, + &resource.record_context.revision_identifier.source_column, + &resource.record_context.lifecycle_state.source_column, + &resource.record_context.recorded_at.source_column, + ] + .contains(&column) + { + self.error( + "geometry.column_collision", + &format!("{location}.source.{field}"), + "geometry carriers must not reuse Registry Core or scalar property columns", + ); + } + if let Some(observed) = observed_view.and_then(|view| { + view.columns.iter().find(|candidate| candidate.name == **column) + }) { + if !compatible_coordinate_type(&observed.declared_type) { + self.error( + "geometry.declared_type_incompatible", + &format!("{location}.source.{field}"), + "geometry coordinates require numeric SQLite declarations", + ); + } + } + } + if longitude_column == latitude_column { + self.error( + "geometry.column_collision", + &format!("{location}.source"), + "longitude and latitude require distinct carrier columns", + ); + } + let classification = effective_classification( + self.contract, + &resource.classification_defaults, + Some(&geometry.classification), + ); + let Some(classification) = classification else { + self.error( + "classification.geometry_incomplete", + &format!("{location}.classification"), + "the primary geometry classification is incomplete after defaults", + ); + return None; + }; + if classification.privacy.trim().is_empty() + || classification.institutional.trim().is_empty() + { + self.error( + "classification.geometry_empty", + &format!("{location}.classification"), + "effective privacy and institutional classifications must be non-empty", + ); + } + self.validate_review_status( + &classification, + &format!("{location}.classification"), + ); + for (column, field) in [ + (longitude_column, "longitudeColumn"), + (latitude_column, "latitudeColumn"), + ] { + if let Some(source_override) = + resource.source_column_classifications.get(column) + { + let carrier_classification = effective_classification( + self.contract, + &classification_to_partial(&classification), + Some(source_override), + ); + if carrier_classification + .as_ref() + .is_some_and(|carrier| carrier.privacy != classification.privacy) + { + self.error( + "classification.geometry_carrier_privacy_mismatch", + &format!( + "{root}.sourceColumnClassifications.{column}.privacy" + ), + &format!( + "the {field} carrier privacy classification must match the published primary geometry" + ), + ); + } + } + } + let semantic_iri = match expand_local_term( + &self.contract.semantics.local_vocabulary, + &geometry.semantic_term, + ) { + Some(term) => term, + None => { + self.error( + "semantics.geometry_term_invalid", + &format!("{location}.semanticTerm"), + "a geometry semantic term must be local:Name or an absolute HTTP or HTTPS IRI", + ); + geometry.semantic_term.clone() + } + }; + property_columns + .entry(longitude_column.as_str()) + .or_default() + .push((geometry.name.as_str(), classification.clone(), false)); + property_columns + .entry(latitude_column.as_str()) + .or_default() + .push((geometry.name.as_str(), classification.clone(), false)); + Some(CompiledPrimaryGeometry { + name: geometry.name.clone(), + label: geometry.label.clone(), + description: geometry.description.clone(), + semantic_iri, + source_required: geometry.source_required, + crs: geometry.crs.clone(), + longitude_column: longitude_column.clone(), + latitude_column: latitude_column.clone(), + classification, + }) + }); + let mut disclosures = Vec::with_capacity(resource.disclosure_profiles.len()); let mut disclosure_names = HashSet::new(); for (name, disclosure) in resource.disclosure_profiles.iter() { @@ -901,10 +1078,22 @@ impl<'a> Compiler<'a> { maximum_handling = maximum_handling.max(property.classification.handling); } + None if primary_geometry + .as_ref() + .is_some_and(|geometry| geometry.name == *property_name) => + { + maximum_handling = maximum_handling.max( + primary_geometry + .as_ref() + .expect("checked primary geometry") + .classification + .handling, + ); + } None => self.error( "disclosure.property_unknown", &location, - "a disclosure profile names no published property", + "a disclosure profile names no published property or primary geometry", ), } } @@ -989,6 +1178,7 @@ impl<'a> Compiler<'a> { let operation = self.compile_list( resource, &properties, + primary_geometry.as_ref(), &disclosures, observed_view, observed_columns.as_ref(), @@ -1003,6 +1193,7 @@ impl<'a> Compiler<'a> { if let Some(operation) = self.compile_simple_operation( resource, &properties, + primary_geometry.as_ref(), &disclosures, observed_columns.as_ref(), &root, @@ -1114,6 +1305,7 @@ impl<'a> Compiler<'a> { if let Some(mut operation) = self.compile_simple_operation( resource, &properties, + primary_geometry.as_ref(), &disclosures, observed_columns.as_ref(), &location, @@ -1143,6 +1335,7 @@ impl<'a> Compiler<'a> { let column_accounting = self.compile_column_accounting( resource, &properties, + primary_geometry.as_ref(), &operations, &property_columns, &core, @@ -1204,6 +1397,7 @@ impl<'a> Compiler<'a> { ), }, properties, + primary_geometry, disclosure_profiles: disclosures, operations, column_accounting, @@ -1218,6 +1412,7 @@ impl<'a> Compiler<'a> { &mut self, resource: &crate::contract::ResourceDefinition, properties: &[CompiledProperty], + primary_geometry: Option<&CompiledPrimaryGeometry>, disclosures: &[CompiledDisclosureProfile], observed_columns: Option<&BTreeSet<&str>>, root: &str, @@ -1310,7 +1505,12 @@ impl<'a> Compiler<'a> { access, disclosure_profile: disclosure.id.clone(), selectable_properties: disclosure.properties.clone(), - projected_columns: projected_columns(resource, properties, &disclosure.properties), + projected_columns: projected_columns( + resource, + properties, + primary_geometry, + &disclosure.properties, + ), processing_handling: Handling::Public, disclosure_handling: disclosure.maximum_handling, transform_inventory: disclosure @@ -1368,6 +1568,7 @@ impl<'a> Compiler<'a> { source: resource.source.source.clone(), view: resource.source.view.clone(), filters: Vec::new(), + spatial_bbox: None, selectors: Vec::new(), order_by: Vec::new(), allow_unfiltered: false, @@ -1382,6 +1583,7 @@ impl<'a> Compiler<'a> { &mut self, resource: &crate::contract::ResourceDefinition, properties: &[CompiledProperty], + primary_geometry: Option<&CompiledPrimaryGeometry>, disclosures: &[CompiledDisclosureProfile], observed_view: Option<&crate::model::ObservedView>, observed_columns: Option<&BTreeSet<&str>>, @@ -1391,6 +1593,7 @@ impl<'a> Compiler<'a> { let mut operation = self.compile_simple_operation( resource, properties, + primary_geometry, disclosures, observed_columns, root, @@ -1414,13 +1617,49 @@ impl<'a> Compiler<'a> { "the governed order-key count exceeds the product ceiling", ); } - if list.filters.is_empty() && !list.allow_unfiltered { + if list.filters.is_empty() && list.spatial_query.is_none() && !list.allow_unfiltered { self.error( "list.no_reachable_query", &location, "a list without filters must allow the empty filter set", ); } + if let Some(spatial_query) = &list.spatial_query { + let Some(geometry) = primary_geometry else { + self.error( + "list.spatial_query_without_geometry", + &format!("{location}.spatialQuery"), + "a bbox query requires a compiled primary geometry", + ); + return Some(operation); + }; + let bbox = &spatial_query.bbox; + if geometry.classification.privacy != "non-personal" { + self.error( + "list.bbox_personal_forbidden", + &format!("{location}.spatialQuery.bbox"), + "the initial bbox search profile permits only non-personal geometry", + ); + } + if bbox.maximum_longitude_span_degrees == 0 + || bbox.maximum_longitude_span_degrees > 360 + || bbox.maximum_latitude_span_degrees == 0 + || bbox.maximum_latitude_span_degrees > 180 + { + self.error( + "list.bbox_bound_invalid", + &format!("{location}.spatialQuery.bbox"), + "bbox spans must be positive and no larger than the CRS84 world extent", + ); + } + operation.pattern = ConsultationPattern::Search; + operation.query.spatial_bbox = Some(CompiledSpatialBboxQuery { + longitude_column: geometry.longitude_column.clone(), + latitude_column: geometry.latitude_column.clone(), + maximum_longitude_span_degrees: bbox.maximum_longitude_span_degrees, + maximum_latitude_span_degrees: bbox.maximum_latitude_span_degrees, + }); + } if list.pagination.default_page_size == 0 || list.pagination.maximum_page_size == 0 || list.pagination.maximum_page_size > MAXIMUM_LIST_PAGE_SIZE @@ -1774,6 +2013,7 @@ impl<'a> Compiler<'a> { &mut self, resource: &crate::contract::ResourceDefinition, properties: &[CompiledProperty], + primary_geometry: Option<&CompiledPrimaryGeometry>, operations: &[CompiledOperation], property_columns: &HashMap<&str, Vec<(&str, EffectiveClassification, bool)>>, core: &[(&str, ColumnUse); 4], @@ -1789,12 +2029,28 @@ impl<'a> Compiler<'a> { .or_default() .insert(ColumnUse::Property(property.name.clone())); } + if let Some(geometry) = primary_geometry { + uses.entry(&geometry.longitude_column) + .or_default() + .insert(ColumnUse::GeometryLongitude(geometry.name.clone())); + uses.entry(&geometry.latitude_column) + .or_default() + .insert(ColumnUse::GeometryLatitude(geometry.name.clone())); + } for operation in operations { for filter in &operation.query.filters { uses.entry(&filter.source_column) .or_default() .insert(ColumnUse::Filter(filter.parameter.clone())); } + if let Some(bbox) = &operation.query.spatial_bbox { + uses.entry(&bbox.longitude_column) + .or_default() + .insert(ColumnUse::SpatialBbox(operation.identifier.clone())); + uses.entry(&bbox.latitude_column) + .or_default() + .insert(ColumnUse::SpatialBbox(operation.identifier.clone())); + } for column in &operation.query.order_by { uses.entry(column).or_default().insert(ColumnUse::Order); } @@ -1818,6 +2074,26 @@ impl<'a> Compiler<'a> { } } } + if let Some(geometry) = primary_geometry { + for column in [&geometry.longitude_column, &geometry.latitude_column] { + if uses.get(column.as_str()).is_some_and(|column_uses| { + column_uses.iter().any(|usage| { + !matches!( + usage, + ColumnUse::GeometryLongitude(_) + | ColumnUse::GeometryLatitude(_) + | ColumnUse::SpatialBbox(_) + ) + }) + }) { + self.error( + "geometry.column_collision", + &format!("{root}.primaryGeometry.source"), + "geometry carriers cannot also serve Registry Core, properties, selectors, ordering, filters, or row bindings", + ); + } + } + } if let Some(columns) = observed_columns { for column in columns { if !uses.contains_key(column) { @@ -1945,6 +2221,10 @@ impl<'a> Compiler<'a> { .map(|filter| filter.source_column.as_str()), ); referenced.extend(operation.query.order_by.iter().map(String::as_str)); + if let Some(spatial) = &operation.query.spatial_bbox { + referenced.insert(&spatial.longitude_column); + referenced.insert(&spatial.latitude_column); + } referenced.extend( operation .query @@ -2801,6 +3081,7 @@ fn validate_disclosure_access( fn projected_columns( resource: &crate::contract::ResourceDefinition, properties: &[CompiledProperty], + primary_geometry: Option<&CompiledPrimaryGeometry>, disclosure: &[String], ) -> Vec { let mut columns = Vec::new(); @@ -2818,6 +3099,9 @@ fn projected_columns( for name in disclosure { if let Some(property) = properties.iter().find(|property| property.name == *name) { push_unique(&mut columns, &property.source_column); + } else if let Some(geometry) = primary_geometry.filter(|geometry| geometry.name == *name) { + push_unique(&mut columns, &geometry.longitude_column); + push_unique(&mut columns, &geometry.latitude_column); } } columns @@ -2917,6 +3201,16 @@ fn compatible_declared_type(data_type: DataType, declared_type: &str) -> bool { } } +fn compatible_coordinate_type(declared_type: &str) -> bool { + let declared = declared_type.trim().to_ascii_uppercase(); + declared.contains("INT") + || declared.contains("REAL") + || declared.contains("FLOA") + || declared.contains("DOUB") + || declared.contains("NUM") + || declared.contains("DEC") +} + fn transform_source_type(transform: Option<&CompiledTransform>, output_type: DataType) -> DataType { match transform { Some(CompiledTransform::PartialString { .. }) => DataType::String, @@ -3959,6 +4253,177 @@ pub(crate) mod tests { .is_none()); } + #[test] + fn absent_spatial_fields_preserve_the_authored_contract_revision() { + let contract = RegistryContract::parse_yaml(valid_contract()).expect("strict contract"); + let value = serde_json::to_value(&contract).expect("contract serializes"); + assert!(value["resources"][0].get("primaryGeometry").is_none()); + assert!(value["resources"][0]["operations"]["read"] + .get("spatialQuery") + .is_none()); + let compiled = compile_contract_with_governed_files( + &contract, + &[observed_schema()], + CompileProfile::Production, + &governed_files(), + ) + .expect("legacy contract compiles"); + assert_eq!(compiled.resources[0].operations[0].representations.len(), 1); + assert_eq!( + compiled.resources[0].operations[0].representations[0].id, + "public" + ); + } + + #[test] + fn exact_point_bbox_compiles_as_a_governed_search() { + let contract = spatial_contract(true); + let governed_files = governed_files_for(&contract); + let compiled = compile_contract_with_governed_files( + &contract, + &[spatial_observed_schema()], + CompileProfile::Production, + &governed_files, + ) + .expect("spatial contract compiles"); + let resource = &compiled.resources[0]; + let geometry = resource.primary_geometry.as_ref().expect("geometry"); + assert_eq!(geometry.crs, CRS84); + assert_eq!(geometry.longitude_column, "longitude"); + let operation = &resource.operations[0]; + assert_eq!(operation.pattern, ConsultationPattern::Search); + assert!(!operation.query.allow_unfiltered); + assert_eq!( + operation + .query + .spatial_bbox + .as_ref() + .expect("bbox") + .maximum_longitude_span_degrees, + 10 + ); + let representation = &operation.representations[0]; + assert!(representation + .projected_columns + .iter() + .any(|column| column == "longitude")); + assert!(representation + .selectable_properties + .iter() + .any(|property| property == "location")); + assert!(resource.column_accounting.iter().any(|account| { + account.column == "latitude" + && account + .uses + .contains(&ColumnUse::GeometryLatitude("location".into())) + && account + .uses + .contains(&ColumnUse::SpatialBbox("record.list".into())) + })); + } + + #[test] + fn spatial_contract_rejects_ambiguous_or_unsafe_shapes() { + let assert_code = |mut value: serde_json::Value, code: &str| { + let contract = serde_json::from_value::(value.take()) + .expect("strict contract value"); + let report = compile_contract_with_governed_files( + &contract, + &[spatial_observed_schema()], + CompileProfile::Production, + &governed_files(), + ) + .expect_err("invalid spatial contract is refused"); + assert!( + report.diagnostics.iter().any(|item| item.code == code), + "missing {code} in {:?}", + report.diagnostics + ); + }; + + let mut collision = spatial_contract_value(true); + collision["resources"][0]["primaryGeometry"]["name"] = serde_json::json!("name"); + assert_code(collision, "geometry.name_collision"); + + let mut carrier_collision = spatial_contract_value(true); + carrier_collision["resources"][0]["primaryGeometry"]["source"]["longitudeColumn"] = + serde_json::json!("name"); + assert_code(carrier_collision, "geometry.column_collision"); + + let mut row_binding_collision = spatial_contract_value(true); + row_binding_collision["resources"][0]["operations"]["list"]["representations"]["public"] + ["access"] = serde_json::json!({ + "scope": "registry:records:list", + "authorityRowBinding": {"principal": true, "sourceColumn": "longitude"} + }); + assert_code(row_binding_collision, "geometry.column_collision"); + + let mut wrong_crs = spatial_contract_value(true); + wrong_crs["resources"][0]["primaryGeometry"]["crs"] = serde_json::json!("EPSG:3857"); + assert_code(wrong_crs, "geometry.crs_unsupported"); + + let mut oversized = spatial_contract_value(true); + oversized["resources"][0]["operations"]["list"]["spatialQuery"]["bbox"] + ["maximumLatitudeSpanDegrees"] = serde_json::json!(181); + assert_code(oversized, "list.bbox_bound_invalid"); + + let mut personal = spatial_contract_value(true); + personal["resources"][0]["primaryGeometry"]["classification"] = serde_json::json!({ + "privacy": "personal" + }); + assert_code(personal, "list.bbox_personal_forbidden"); + + let mut personal_carrier = spatial_contract_value(true); + personal_carrier["resources"][0]["sourceColumnClassifications"]["longitude"] = + serde_json::json!({"privacy": "personal"}); + assert_code( + personal_carrier, + "classification.geometry_carrier_privacy_mismatch", + ); + + let mut nonpublic = spatial_contract_value(false); + nonpublic["resources"][0]["primaryGeometry"]["classification"] = serde_json::json!({ + "handling": "internal" + }); + assert_code(nonpublic, "access.public_nonpublic_forbidden"); + + let mut too_many_properties = spatial_contract_value(true); + let properties = too_many_properties["resources"][0]["properties"] + .as_object_mut() + .expect("properties object"); + let template = properties.get("name").expect("name property").clone(); + for index in 1..MAXIMUM_PROPERTIES_PER_RESOURCE { + properties.insert(format!("name{index}"), template.clone()); + } + assert_code(too_many_properties, "property.bound_exceeded"); + } + + #[test] + fn geometry_disclosure_is_representation_scoped() { + let mut undisclosed = spatial_contract_value(false); + undisclosed["resources"][0]["disclosureProfiles"]["public"]["properties"] = + serde_json::json!(["name"]); + let contract = + serde_json::from_value::(undisclosed).expect("strict contract value"); + let governed_files = governed_files_for(&contract); + let compiled = compile_contract_with_governed_files( + &contract, + &[spatial_observed_schema()], + CompileProfile::Production, + &governed_files, + ) + .expect("geometry may remain outside one governed representation"); + let representation = &compiled.resources[0].operations[0].representations[0]; + assert!(!representation + .selectable_properties + .iter() + .any(|property| property == "location")); + assert!(!representation + .projected_columns + .iter() + .any(|column| column == "longitude" || column == "latitude")); + } + #[test] fn public_record_cannot_reference_operator_only_semantics() { let yaml = valid_contract().replace( @@ -3999,13 +4464,84 @@ pub(crate) mod tests { } } + pub(crate) fn spatial_observed_schema() -> ObservedSourceSchema { + let mut schema = observed_schema(); + let columns = &mut schema.views[0].columns; + columns.extend(["longitude", "latitude"].into_iter().map(|name| { + crate::model::ObservedColumn { + name: name.into(), + declared_type: "REAL".into(), + nullable: false, + primary_key: false, + } + })); + schema + } + + pub(crate) fn spatial_contract(list: bool) -> RegistryContract { + serde_json::from_value(spatial_contract_value(list)).expect("strict spatial contract") + } + + fn spatial_contract_value(list: bool) -> serde_json::Value { + let contract = RegistryContract::parse_yaml(valid_contract()).expect("strict contract"); + let mut value = serde_json::to_value(contract).expect("contract serializes"); + value["resources"][0]["primaryGeometry"] = serde_json::json!({ + "name": "location", + "label": "Location", + "description": "Authoritative point location", + "semanticTerm": "local:location", + "sourceRequired": true, + "crs": CRS84, + "source": { + "longitudeColumn": "longitude", + "latitudeColumn": "latitude" + }, + "classification": {} + }); + value["resources"][0]["disclosureProfiles"]["public"]["properties"] = + serde_json::json!(["name", "location"]); + if list { + value["resources"][0]["operations"] = serde_json::json!({ + "list": { + "defaultRepresentation": "public", + "representations": { + "public": { + "access": "public", + "disclosureProfile": "public" + } + }, + "filters": [], + "spatialQuery": {"bbox": { + "maximumLongitudeSpanDegrees": 10, + "maximumLatitudeSpanDegrees": 10 + }}, + "allowUnfiltered": false, + "orderBy": ["name"], + "pagination": {"defaultPageSize": 2, "maximumPageSize": 10} + } + }); + value["resources"][0]["processingDescriptions"][0]["operationRefs"] = + serde_json::json!(["list"]); + } + value + } + pub(crate) fn governed_files() -> GovernedFileSet { let contract = RegistryContract::parse_yaml(valid_contract()).expect("strict contract"); governed_files_for(&contract) } pub(crate) fn governed_files_for(contract: &RegistryContract) -> GovernedFileSet { - let compiled = compile_contract(contract, &[observed_schema()], CompileProfile::Production) + let observed = if contract + .resources + .iter() + .any(|resource| resource.primary_geometry.is_some()) + { + spatial_observed_schema() + } else { + observed_schema() + }; + let compiled = compile_contract(contract, &[observed], CompileProfile::Production) .expect("inventory compiles"); let inventory_digest = classification_inventory_digest(&compiled).expect("inventory digest"); diff --git a/crates/registry-relay-v2/src/contract.rs b/crates/registry-relay-v2/src/contract.rs index ea116dc15..3bb13b0e9 100644 --- a/crates/registry-relay-v2/src/contract.rs +++ b/crates/registry-relay-v2/src/contract.rs @@ -244,6 +244,8 @@ pub struct ResourceDefinition { #[serde(default)] pub source_column_classifications: OrderedMap, pub properties: OrderedMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub primary_geometry: Option, pub disclosure_profiles: OrderedMap, pub operations: Operations, #[serde(default)] @@ -327,6 +329,27 @@ pub struct PropertyDefinition { pub classification: ClassificationPartial, } +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct PrimaryGeometryDefinition { + pub name: String, + pub label: String, + pub description: String, + pub semantic_term: String, + pub source_required: bool, + pub crs: String, + pub source: PointColumns, + #[serde(default)] + pub classification: ClassificationPartial, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct PointColumns { + pub longitude_column: String, + pub latitude_column: String, +} + #[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "kebab-case")] pub enum DataType { @@ -399,6 +422,8 @@ pub struct ListOperation { pub representations: OrderedMap, #[serde(default)] pub filters: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub spatial_query: Option, pub allow_unfiltered: bool, pub order_by: Vec, pub pagination: Pagination, @@ -427,6 +452,19 @@ pub struct RepresentationDefinition { pub disclosure_profile: String, } +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct SpatialQuery { + pub bbox: BboxQuery, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct BboxQuery { + pub maximum_longitude_span_degrees: u16, + pub maximum_latitude_span_degrees: u16, +} + #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] #[serde(deny_unknown_fields, rename_all = "camelCase")] pub struct ClassificationReviewDocument { diff --git a/crates/registry-relay-v2/src/cursor.rs b/crates/registry-relay-v2/src/cursor.rs index 233c2bdce..04a6ddfe9 100644 --- a/crates/registry-relay-v2/src/cursor.rs +++ b/crates/registry-relay-v2/src/cursor.rs @@ -41,6 +41,17 @@ pub struct CursorPayload { pub selected_fields_digest: String, pub authorization_digest: String, pub order_digest: String, + /// Canonical CRS84 bbox values, retained so a continuation can execute + /// the exact same spatial predicate without accepting fresh query input. + #[serde(default)] + pub bbox: Option<[String; 4]>, + /// The negotiated response kind and profile are part of the page chain. + /// They contain no caller or registry data and are integrity protected by + /// the cursor envelope. + #[serde(default = "default_response_format")] + pub response_format: String, + #[serde(default)] + pub response_profile: Option, pub last_record_identifier: String, #[serde(default)] pub page_size: u32, @@ -97,6 +108,9 @@ impl CursorPayload { selected_fields_digest: bindings.selected_fields_digest, authorization_digest: bindings.authorization_digest, order_digest: bindings.order_digest, + bbox: None, + response_format: default_response_format(), + response_profile: None, last_record_identifier: bindings.last_record_identifier, page_size: 0, filters: BTreeMap::new(), @@ -119,6 +133,19 @@ impl CursorPayload { self.last_order_values = last_order_values; self } + + #[must_use] + pub fn with_response_context( + mut self, + bbox: Option<[String; 4]>, + response_format: String, + response_profile: Option, + ) -> Self { + self.bbox = bbox; + self.response_format = response_format; + self.response_profile = response_profile; + self + } } /// Cursor protection key. `Debug` intentionally cannot expose key material. @@ -256,12 +283,19 @@ pub fn require_same_request( || cursor.selected_fields_digest != request.selected_fields_digest || cursor.authorization_digest != request.authorization_digest || cursor.order_digest != request.order_digest + || cursor.bbox != request.bbox + || cursor.response_format != request.response_format + || cursor.response_profile != request.response_profile { return Err(CursorError::Mismatch); } Ok(()) } +fn default_response_format() -> String { + "json".to_owned() +} + #[must_use] pub fn now_unix_seconds() -> u64 { SystemTime::now() @@ -400,4 +434,38 @@ mod tests { ); } } + + #[test] + fn cursor_cannot_cross_spatial_or_representation_contexts() { + let spatial = payload().with_response_context( + Some([ + "100".to_owned(), + "10".to_owned(), + "101".to_owned(), + "11".to_owned(), + ]), + "geojson".to_owned(), + Some("json-fg".to_owned()), + ); + let mut changed_bbox = spatial.clone(); + changed_bbox.bbox.as_mut().expect("bbox")[2] = "102".to_owned(); + assert_eq!( + require_same_request(&spatial, &changed_bbox), + Err(CursorError::Mismatch) + ); + + let mut changed_profile = spatial.clone(); + changed_profile.response_profile = Some("rfc7946".to_owned()); + assert_eq!( + require_same_request(&spatial, &changed_profile), + Err(CursorError::Mismatch) + ); + + let key = CursorKey::new(vec![7; 32]).expect("key is sufficient"); + let encoded = encode(&key, &spatial).expect("spatial cursor encodes"); + assert_eq!( + decode(&key, &encoded, 1).expect("spatial cursor decodes"), + spatial + ); + } } diff --git a/crates/registry-relay-v2/src/diff.rs b/crates/registry-relay-v2/src/diff.rs index 3efbe0a8d..8834982c5 100644 --- a/crates/registry-relay-v2/src/diff.rs +++ b/crates/registry-relay-v2/src/diff.rs @@ -50,6 +50,9 @@ pub enum ChangeClass { PropertyAdded, PropertyRemoved, PropertyMeaningChanged, + GeometryAdded, + GeometryRemoved, + GeometryChanged, TransformationChanged, HandlingRelaxed, HandlingTightened, @@ -64,6 +67,11 @@ pub enum ChangeClass { FilterAdded, FilterRemoved, FilterChanged, + SpatialQueryAdded, + SpatialQueryRemoved, + SpatialQueryExpanded, + SpatialQueryNarrowed, + SpatialQueryChanged, UnfilteredEnabled, UnfilteredDisabled, SelectorChanged, @@ -250,13 +258,13 @@ fn diff_resource( "a Registry Core binding or reference changed", ); } - if previous.column_accounting != current.column_accounting { + if !same_column_classifications(&previous.column_accounting, ¤t.column_accounting) { push( changes, ChangeClass::ClassificationChanged, ChangeImpact::Breaking, format!("{root}.sourceColumnClassifications"), - "effective classifications or uses of reviewed source columns changed", + "effective classifications of reviewed source columns changed", ); } if previous.processing_descriptions != current.processing_descriptions { @@ -268,6 +276,71 @@ fn diff_resource( "the reviewed processing description set changed", ); } + match (&previous.primary_geometry, ¤t.primary_geometry) { + (None, Some(_)) => push( + changes, + ChangeClass::GeometryAdded, + ChangeImpact::Widening, + format!("{root}.primaryGeometry"), + "a publishable primary geometry was added", + ), + (Some(_), None) => push( + changes, + ChangeClass::GeometryRemoved, + ChangeImpact::Breaking, + format!("{root}.primaryGeometry"), + "the primary geometry was removed", + ), + (Some(before), Some(after)) => { + let location = format!("{root}.primaryGeometry"); + if before.name != after.name + || before.label != after.label + || before.description != after.description + || before.semantic_iri != after.semantic_iri + || before.source_required != after.source_required + || before.crs != after.crs + || before.longitude_column != after.longitude_column + || before.latitude_column != after.latitude_column + { + push( + changes, + ChangeClass::GeometryChanged, + ChangeImpact::Breaking, + location.clone(), + "the primary geometry name, documentation, meaning, source binding, CRS, or requiredness changed", + ); + } + if after.classification.handling < before.classification.handling { + push( + changes, + ChangeClass::HandlingRelaxed, + ChangeImpact::Widening, + format!("{location}.classification.handling"), + "primary geometry handling became less restrictive", + ); + } else if after.classification.handling > before.classification.handling { + push( + changes, + ChangeClass::HandlingTightened, + ChangeImpact::Narrowing, + format!("{location}.classification.handling"), + "primary geometry handling became more restrictive", + ); + } + if classification_context(&before.classification) + != classification_context(&after.classification) + { + push( + changes, + ChangeClass::ClassificationChanged, + ChangeImpact::Breaking, + format!("{location}.classification"), + "primary geometry privacy, institutional, review, scheme, version, or provenance classification changed", + ); + } + } + _ => {} + } let before_properties = previous .properties @@ -494,6 +567,12 @@ fn diff_operation( _ => {} } } + diff_spatial_query( + previous.query.spatial_bbox.as_ref(), + current.query.spatial_bbox.as_ref(), + location, + changes, + ); match ( previous.query.allow_unfiltered, current.query.allow_unfiltered, @@ -620,6 +699,72 @@ fn diff_representation( diff_access(&previous.access, ¤t.access, location, changes); } +fn diff_spatial_query( + previous: Option<&crate::model::CompiledSpatialBboxQuery>, + current: Option<&crate::model::CompiledSpatialBboxQuery>, + location: &str, + changes: &mut Vec, +) { + match (previous, current) { + (None, Some(_)) => push( + changes, + ChangeClass::SpatialQueryAdded, + ChangeImpact::Widening, + format!("{location}.spatialQuery.bbox"), + "an exact point bbox query was added", + ), + (Some(_), None) => push( + changes, + ChangeClass::SpatialQueryRemoved, + ChangeImpact::Breaking, + format!("{location}.spatialQuery.bbox"), + "the exact point bbox query was removed", + ), + (Some(before), Some(after)) if before != after => { + let location = format!("{location}.spatialQuery.bbox"); + if before.longitude_column != after.longitude_column + || before.latitude_column != after.latitude_column + { + push( + changes, + ChangeClass::SpatialQueryChanged, + ChangeImpact::Breaking, + location, + "the exact point bbox source binding changed", + ); + } else { + let expanded = after.maximum_longitude_span_degrees + >= before.maximum_longitude_span_degrees + && after.maximum_latitude_span_degrees >= before.maximum_latitude_span_degrees; + let narrowed = after.maximum_longitude_span_degrees + <= before.maximum_longitude_span_degrees + && after.maximum_latitude_span_degrees <= before.maximum_latitude_span_degrees; + let (class, impact, description) = if expanded { + ( + ChangeClass::SpatialQueryExpanded, + ChangeImpact::Widening, + "the accepted bbox span expanded", + ) + } else if narrowed { + ( + ChangeClass::SpatialQueryNarrowed, + ChangeImpact::Narrowing, + "the accepted bbox span narrowed", + ) + } else { + ( + ChangeClass::SpatialQueryChanged, + ChangeImpact::Breaking, + "the bbox span bounds changed non-monotonically", + ) + }; + push(changes, class, impact, location, description); + } + } + _ => {} + } +} + fn classification_context( value: &crate::model::EffectiveClassification, ) -> ( @@ -644,6 +789,16 @@ fn classification_context( ) } +fn same_column_classifications( + previous: &[crate::model::ColumnAccount], + current: &[crate::model::ColumnAccount], +) -> bool { + previous.len() == current.len() + && previous.iter().zip(current).all(|(before, after)| { + before.column == after.column && before.classification == after.classification + }) +} + fn diff_pagination( previous: Option<&crate::model::CompiledPagination>, current: Option<&crate::model::CompiledPagination>, @@ -1089,4 +1244,135 @@ mod tests { .iter() .any(|change| change.class == ChangeClass::RequestBoundExpanded)); } + + #[test] + fn spatial_changes_are_explicitly_classified() { + let previous = compiled(); + let mut current = previous.clone(); + let classification = current.resources[0].properties[0].classification.clone(); + current.resources[0].primary_geometry = Some(crate::model::CompiledPrimaryGeometry { + name: "location".into(), + label: "Location".into(), + description: "Authoritative point".into(), + semantic_iri: "https://example.invalid/location".into(), + source_required: true, + crs: "http://www.opengis.net/def/crs/OGC/0/CRS84".into(), + longitude_column: "longitude".into(), + latitude_column: "latitude".into(), + classification, + }); + let operation = &mut current.resources[0].operations[0]; + operation.representations[0] + .selectable_properties + .push("location".into()); + operation.representations[0] + .projected_columns + .extend(["longitude".into(), "latitude".into()]); + operation.query.spatial_bbox = Some(crate::model::CompiledSpatialBboxQuery { + longitude_column: "longitude".into(), + latitude_column: "latitude".into(), + maximum_longitude_span_degrees: 10, + maximum_latitude_span_degrees: 10, + }); + + let report = diff_registries(&previous, ¤t); + for class in [ + ChangeClass::GeometryAdded, + ChangeClass::DisclosureExpanded, + ChangeClass::SpatialQueryAdded, + ] { + assert!( + report.changes.iter().any(|change| change.class == class), + "missing {class:?}" + ); + } + + let mut expanded = current.clone(); + expanded.resources[0].operations[0] + .query + .spatial_bbox + .as_mut() + .expect("bbox") + .maximum_longitude_span_degrees = 20; + let report = diff_registries(¤t, &expanded); + assert!(report + .changes + .iter() + .any(|change| change.class == ChangeClass::SpatialQueryExpanded)); + } + + #[test] + fn spatial_query_use_changes_do_not_masquerade_as_classification_changes() { + let previous = compiled(); + let mut added = previous.clone(); + let operation_identifier = added.resources[0].operations[0].identifier.clone(); + added.resources[0].operations[0].query.spatial_bbox = + Some(crate::model::CompiledSpatialBboxQuery { + longitude_column: "name".into(), + latitude_column: "name".into(), + maximum_longitude_span_degrees: 10, + maximum_latitude_span_degrees: 10, + }); + added.resources[0] + .column_accounting + .iter_mut() + .find(|account| account.column == "name") + .expect("published property column is accounted") + .uses + .push(crate::model::ColumnUse::SpatialBbox(operation_identifier)); + + let report = diff_registries(&previous, &added); + assert_eq!( + report + .changes + .iter() + .map(|change| (change.class, change.impact)) + .collect::>(), + [(ChangeClass::SpatialQueryAdded, ChangeImpact::Widening)] + ); + + let mut expanded = added.clone(); + expanded.resources[0].operations[0] + .query + .spatial_bbox + .as_mut() + .expect("bbox") + .maximum_longitude_span_degrees = 20; + let report = diff_registries(&added, &expanded); + assert_eq!( + report + .changes + .iter() + .map(|change| (change.class, change.impact)) + .collect::>(), + [(ChangeClass::SpatialQueryExpanded, ChangeImpact::Widening)] + ); + + let mut narrowed = expanded.clone(); + narrowed.resources[0].operations[0] + .query + .spatial_bbox + .as_mut() + .expect("bbox") + .maximum_longitude_span_degrees = 5; + let report = diff_registries(&expanded, &narrowed); + assert_eq!( + report + .changes + .iter() + .map(|change| (change.class, change.impact)) + .collect::>(), + [(ChangeClass::SpatialQueryNarrowed, ChangeImpact::Narrowing)] + ); + + let report = diff_registries(&added, &previous); + assert_eq!( + report + .changes + .iter() + .map(|change| (change.class, change.impact)) + .collect::>(), + [(ChangeClass::SpatialQueryRemoved, ChangeImpact::Breaking)] + ); + } } diff --git a/crates/registry-relay-v2/src/fixture_contract.rs b/crates/registry-relay-v2/src/fixture_contract.rs index 4c5632ae3..9f2409d63 100644 --- a/crates/registry-relay-v2/src/fixture_contract.rs +++ b/crates/registry-relay-v2/src/fixture_contract.rs @@ -92,6 +92,34 @@ pub struct FixtureExpectation { pub body_empty: Option, #[serde(default)] pub etag_same_as: Option, + #[serde(default)] + pub geo_json_root: Option, + #[serde(default)] + pub geometry_type: Option, + #[serde(default)] + pub representation_profile: Option, +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum FixtureGeoJsonRoot { + Feature, + FeatureCollection, +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +pub enum FixtureGeometryType { + #[serde(rename = "Point")] + Point, + #[serde(rename = "null")] + Null, +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum FixtureRepresentationProfile { + Rfc7946, + JsonFg, } #[derive(Debug, Error)] diff --git a/crates/registry-relay-v2/src/fixtures.rs b/crates/registry-relay-v2/src/fixtures.rs index 858600b89..311dc5c17 100644 --- a/crates/registry-relay-v2/src/fixtures.rs +++ b/crates/registry-relay-v2/src/fixtures.rs @@ -4,7 +4,7 @@ use std::collections::{BTreeMap, BTreeSet}; use axum::body::{to_bytes, Body}; -use axum::http::header::{AUTHORIZATION, CACHE_CONTROL, CONTENT_TYPE, ETAG, VARY}; +use axum::http::header::{AUTHORIZATION, CACHE_CONTROL, CONTENT_TYPE, ETAG, LINK, VARY}; use axum::http::{Request, StatusCode}; use axum::Router; use base64::engine::general_purpose::URL_SAFE_NO_PAD; @@ -15,8 +15,9 @@ use tower::ServiceExt as _; use crate::auth::{FixturePrincipal, RelayAuthenticator}; pub use crate::fixture_contract::{ - parse_journey, FixtureAuthorization, FixtureError, FixtureExpectation, FixtureJourney, - FixtureMethod, FixtureRequest, FixtureStep, + parse_journey, FixtureAuthorization, FixtureError, FixtureExpectation, FixtureGeoJsonRoot, + FixtureGeometryType, FixtureJourney, FixtureMethod, FixtureRepresentationProfile, + FixtureRequest, FixtureStep, }; use crate::model::{CompiledAccess, CompiledRegistry, OperationKind}; @@ -428,11 +429,7 @@ fn assert_expectations( } let records = response.document.map(response_records).unwrap_or_default(); if let Some(expected) = step.expect.item_count { - let actual = response - .document - .and_then(|value| value.get("items")) - .and_then(Value::as_array) - .map(Vec::len); + let actual = response.document.and_then(response_item_count); if actual != usize::try_from(expected).ok() { mismatch( diagnostics, @@ -518,9 +515,9 @@ fn assert_expectations( ); } if let Some(expected) = step.expect.record_identifier.as_deref() { - let actual = response - .document - .and_then(|value| value.pointer("/data/recordIdentifier")) + let actual = records + .first() + .and_then(|record| record.get("recordIdentifier")) .and_then(Value::as_str); if actual != Some(expected) { mismatch( @@ -531,6 +528,7 @@ fn assert_expectations( ); } } + assert_geojson_expectations(step, response, &location, diagnostics); assert_capabilities(step, response.document, &location, diagnostics); if step.expect.cache.as_deref() == Some("public-snapshot-revalidation") && (response @@ -627,6 +625,132 @@ fn assert_expectations( before == diagnostics.len() } +fn assert_geojson_expectations( + step: &FixtureStep, + response: &ObservedResponse<'_>, + location: &str, + diagnostics: &mut Vec, +) { + let Some(document) = response.document else { + if step.expect.geo_json_root.is_some() + || step.expect.geometry_type.is_some() + || step.expect.representation_profile.is_some() + { + mismatch( + diagnostics, + "fixture.geojson_mismatch", + location, + "GeoJSON response", + ); + } + return; + }; + if let Some(expected) = step.expect.geo_json_root { + let expected = match expected { + FixtureGeoJsonRoot::Feature => "Feature", + FixtureGeoJsonRoot::FeatureCollection => "FeatureCollection", + }; + if document.get("type").and_then(Value::as_str) != Some(expected) { + mismatch( + diagnostics, + "fixture.geojson_root_mismatch", + location, + "GeoJSON root", + ); + } + } + if let Some(expected) = step.expect.geometry_type { + let features = response_features(document); + let mismatch_found = features.is_empty() + || features.iter().any(|feature| match expected { + FixtureGeometryType::Point => { + feature + .get("geometry") + .and_then(|geometry| geometry.get("type")) + .and_then(Value::as_str) + != Some("Point") + } + FixtureGeometryType::Null => !feature.get("geometry").is_some_and(Value::is_null), + }); + if mismatch_found { + mismatch( + diagnostics, + "fixture.geometry_mismatch", + location, + "GeoJSON geometry type", + ); + } + } + let Some(profile) = step.expect.representation_profile else { + return; + }; + if response + .headers + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + != Some("application/geo+json") + { + mismatch( + diagnostics, + "fixture.representation_profile_mismatch", + location, + "GeoJSON content type", + ); + } + let (profile_uri, conformance) = match profile { + FixtureRepresentationProfile::Rfc7946 => { + ("http://www.opengis.net/def/profile/OGC/0/rfc7946", None) + } + FixtureRepresentationProfile::JsonFg => ( + "http://www.opengis.net/def/profile/OGC/0/jsonfg", + Some([ + "http://www.opengis.net/spec/json-fg-1/1.0/conf/core", + "http://www.opengis.net/spec/json-fg-1/1.0/conf/types-schemas", + ]), + ), + }; + let expected_link = format!("<{profile_uri}>; rel=\"profile\""); + if response + .headers + .get(LINK) + .and_then(|value| value.to_str().ok()) + != Some(expected_link.as_str()) + { + mismatch( + diagnostics, + "fixture.representation_profile_mismatch", + location, + "GeoJSON profile link", + ); + } + if let Some(expected) = conformance { + let actual = document + .get("conformsTo") + .and_then(Value::as_array) + .map(|values| { + values + .iter() + .filter_map(Value::as_str) + .collect::>() + }); + if actual != Some(expected.into_iter().collect()) { + mismatch( + diagnostics, + "fixture.representation_profile_mismatch", + location, + "JSON-FG conformance", + ); + } + } else if document.get("conformsTo").is_some() || document.get("featureType").is_some() { + mismatch( + diagnostics, + "fixture.representation_profile_mismatch", + location, + "RFC 7946 profile members", + ); + } +} + fn assert_capabilities( step: &FixtureStep, document: Option<&Value>, @@ -745,11 +869,36 @@ fn query_value( } fn normalized_records(document: &Value) -> Value { + let geometries = response_geometries(document); let mut records = response_records(document) .into_iter() - .cloned() + .enumerate() + .map(|(index, record)| { + let mut record = record.clone(); + let mut geometry = geometries + .get(index) + .and_then(|geometry| *geometry) + .cloned() + .unwrap_or(Value::Null); + if geometry.is_null() { + if let Some(domain) = record.get_mut("domainData").and_then(Value::as_object_mut) { + let geometry_name = domain.iter().find_map(|(name, value)| { + (value.get("type").and_then(Value::as_str) == Some("Point") + && value.get("coordinates").is_some()) + .then(|| name.clone()) + }); + if let Some(name) = geometry_name { + geometry = domain.remove(&name).unwrap_or(Value::Null); + } + } + } + json!({"record": record, "geometry": geometry}) + }) .collect::>(); - for record in &mut records { + for normalized in &mut records { + let Some(record) = normalized.get_mut("record") else { + continue; + }; if let Some(object) = record.as_object_mut() { object.remove("@context"); object.remove("@id"); @@ -770,7 +919,21 @@ fn fixture_token(identifier: &str) -> String { } fn response_records(document: &Value) -> Vec<&Value> { - if let Some(record) = document.get("data") { + if document.get("type").and_then(Value::as_str) == Some("Feature") { + document + .get("properties") + .map_or_else(Vec::new, |record| vec![record]) + } else if document.get("type").and_then(Value::as_str) == Some("FeatureCollection") { + document + .get("features") + .and_then(Value::as_array) + .map_or_else(Vec::new, |features| { + features + .iter() + .filter_map(|feature| feature.get("properties")) + .collect() + }) + } else if let Some(record) = document.get("data") { vec![record] } else { document @@ -780,6 +943,51 @@ fn response_records(document: &Value) -> Vec<&Value> { } } +fn response_item_count(document: &Value) -> Option { + document + .get("items") + .or_else(|| document.get("features")) + .and_then(Value::as_array) + .map(Vec::len) +} + +fn response_geometries(document: &Value) -> Vec> { + if document.get("type").and_then(Value::as_str) == Some("Feature") { + vec![document + .get("geometry") + .filter(|geometry| !geometry.is_null())] + } else if document.get("type").and_then(Value::as_str) == Some("FeatureCollection") { + document + .get("features") + .and_then(Value::as_array) + .map_or_else(Vec::new, |features| { + features + .iter() + .map(|feature| { + feature + .get("geometry") + .filter(|geometry| !geometry.is_null()) + }) + .collect() + }) + } else { + Vec::new() + } +} + +fn response_features(document: &Value) -> Vec<&Value> { + if document.get("type").and_then(Value::as_str) == Some("Feature") { + vec![document] + } else if document.get("type").and_then(Value::as_str) == Some("FeatureCollection") { + document + .get("features") + .and_then(Value::as_array) + .map_or_else(Vec::new, |features| features.iter().collect()) + } else { + Vec::new() + } +} + fn has_registry_core(record: &Value) -> bool { [ "registryIdentifier", @@ -1003,4 +1211,113 @@ steps: assert_eq!(token.split('.').count(), 3); assert!(!token.contains("principal")); } + + #[test] + fn fixture_yaml_accepts_only_the_closed_geojson_expectations() { + let yaml = r#" +schemaVersion: relay.registrystack.org/http-journey/v1alpha1 +registry: urn:example:registry +authorizations: {} +steps: + - id: feature + request: {method: GET, path: /v2/resources/places/records/one} + expect: + status: 200 + geoJsonRoot: feature + geometryType: Point + representationProfile: jsonfg +"#; + let journey = parse_journey(yaml).expect("closed GeoJSON expectations parse"); + assert_eq!( + journey.steps[0].expect.geo_json_root, + Some(FixtureGeoJsonRoot::Feature) + ); + assert_eq!( + journey.steps[0].expect.geometry_type, + Some(FixtureGeometryType::Point) + ); + assert_eq!( + journey.steps[0].expect.representation_profile, + Some(FixtureRepresentationProfile::JsonFg) + ); + + assert!(parse_journey(&yaml.replace("jsonfg", "draft-profile")).is_err()); + } + + #[test] + fn rfc7946_fixture_requires_explicit_null_geometry_and_no_json_fg_members() { + let journey = parse_journey( + r#" +schemaVersion: relay.registrystack.org/http-journey/v1alpha1 +registry: urn:example:registry +authorizations: {} +steps: + - id: feature + request: {method: GET, path: /v2/resources/places/records/one} + expect: + status: 200 + geoJsonRoot: feature + geometryType: "null" + representationProfile: rfc7946 +"#, + ) + .expect("fixture parses"); + let step = &journey.steps[0]; + let mut headers = http::HeaderMap::new(); + headers.insert( + CONTENT_TYPE, + "application/geo+json".parse().expect("header"), + ); + headers.insert( + LINK, + "; rel=\"profile\"" + .parse() + .expect("header"), + ); + + for document in [ + json!({"type": "Feature", "properties": {}}), + json!({ + "type": "Feature", + "geometry": null, + "properties": {}, + "featureType": "places", + }), + ] { + let bytes = serde_json::to_vec(&document).expect("document serializes"); + let mut diagnostics = Vec::new(); + assert!(!assert_expectations( + step, + &ObservedResponse { + status: StatusCode::OK, + headers: &headers, + body: &bytes, + document: Some(&document), + code: None, + }, + &mut BTreeMap::new(), + &BTreeMap::new(), + 0, + &mut diagnostics, + )); + assert!(!diagnostics.is_empty()); + } + + let document = json!({"type": "Feature", "geometry": null, "properties": {}}); + let bytes = serde_json::to_vec(&document).expect("document serializes"); + assert!(assert_expectations( + step, + &ObservedResponse { + status: StatusCode::OK, + headers: &headers, + body: &bytes, + document: Some(&document), + code: None, + }, + &mut BTreeMap::new(), + &BTreeMap::new(), + 0, + &mut Vec::new(), + )); + } } diff --git a/crates/registry-relay-v2/src/model.rs b/crates/registry-relay-v2/src/model.rs index 7705103a8..39328ff0c 100644 --- a/crates/registry-relay-v2/src/model.rs +++ b/crates/registry-relay-v2/src/model.rs @@ -135,6 +135,7 @@ pub struct CompiledResource { pub view: String, pub record_context: CompiledRecordContext, pub properties: Vec, + pub primary_geometry: Option, pub disclosure_profiles: Vec, pub operations: Vec, pub column_accounting: Vec, @@ -194,6 +195,20 @@ impl CompiledTransform { } } +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CompiledPrimaryGeometry { + pub name: String, + pub label: String, + pub description: String, + pub semantic_iri: String, + pub source_required: bool, + pub crs: String, + pub longitude_column: String, + pub latitude_column: String, + pub classification: EffectiveClassification, +} + #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct EffectiveClassification { @@ -270,6 +285,13 @@ pub struct CompiledRepresentation { pub context_reference: String, } +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "kebab-case")] +pub enum RepresentationProfile { + Rfc7946, + JsonFg, +} + #[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "kebab-case")] pub enum CapabilityFamily { @@ -330,6 +352,7 @@ pub struct QueryPlan { pub source: String, pub view: String, pub filters: Vec, + pub spatial_bbox: Option, pub selectors: Vec, pub order_by: Vec, pub allow_unfiltered: bool, @@ -337,6 +360,15 @@ pub struct QueryPlan { pub maximum_request_body_bytes: Option, } +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CompiledSpatialBboxQuery { + pub longitude_column: String, + pub latitude_column: String, + pub maximum_longitude_span_degrees: u16, + pub maximum_latitude_span_degrees: u16, +} + #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct CompiledFilter { @@ -380,6 +412,9 @@ pub enum ColumnUse { LifecycleState, RecordedAt, Property(String), + GeometryLongitude(String), + GeometryLatitude(String), + SpatialBbox(String), Filter(String), Order, Selector(String), diff --git a/crates/registry-relay-v2/src/semantics.rs b/crates/registry-relay-v2/src/semantics.rs index 81d7d1d1a..ae52aaab4 100644 --- a/crates/registry-relay-v2/src/semantics.rs +++ b/crates/registry-relay-v2/src/semantics.rs @@ -4,7 +4,7 @@ use serde_json::{json, Map, Value}; use crate::contract::DataType; -use crate::model::{CompiledProperty, CompiledRegistry, CompiledResource}; +use crate::model::{CompiledPrimaryGeometry, CompiledProperty, CompiledRegistry, CompiledResource}; pub fn local_vocabulary( registry: &CompiledRegistry, @@ -30,6 +30,21 @@ pub fn local_vocabulary( "https://id.registrystack.org/vocab/codelist": property.codelist, })); } + if let Some(geometry) = selected_geometry(resource, selected) { + graph.push(json!({ + "@id": geometry.semantic_iri, + "@type": "rdf:Property", + "rdfs:label": geometry.label, + "rdfs:comment": geometry.description, + "rdfs:domain": {"@id": resource.semantic_class}, + // Relay publishes a bounded GeoJSON value. This deliberately does + // not claim GeoSPARQL semantics or spatial inference support. + "rdfs:range": {"@id": "rdf:JSON"}, + "https://id.registrystack.org/vocab/geometryType": "Point", + "https://id.registrystack.org/vocab/coordinateReferenceSystem": geometry.crs, + "https://id.registrystack.org/vocab/sourceRequired": geometry.source_required, + })); + } json!({ "@context": { "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#", @@ -62,6 +77,16 @@ pub fn json_ld_context( json!({"@id": format!("{core}{field}"), "@type": "@id"}), ); } + if let Some(geometry) = selected_geometry(resource, selected) { + context.insert( + geometry.name.clone(), + json!({ + "@id": geometry.semantic_iri, + "@nest": "domainData", + "@type": "@json", + }), + ); + } for field in ["recordIdentifier", "revisionIdentifier", "lifecycleState"] { context.insert( field.into(), @@ -117,6 +142,12 @@ pub fn full_record_schema(registry: &CompiledRegistry, resource: &CompiledResour .properties .iter() .map(|property| property.name.clone()) + .chain( + resource + .primary_geometry + .iter() + .map(|geometry| geometry.name.clone()), + ) .collect::>(); record_schema( registry, @@ -152,6 +183,18 @@ fn record_schema( domain_required.push(Value::String(property.name.clone())); } } + if let Some(geometry) = selected_geometry(resource, selected) { + let mut schema = point_geometry_schema(); + if let Value::Object(map) = &mut schema { + map.insert("title".into(), json!(geometry.label)); + map.insert("description".into(), json!(geometry.description)); + map.insert("x-registry-crs".into(), json!(geometry.crs)); + } + domain_properties.insert(geometry.name.clone(), schema); + if full && geometry.source_required { + domain_required.push(Value::String(geometry.name.clone())); + } + } let mut domain_data = json!({ "type": "object", "additionalProperties": false, @@ -190,6 +233,27 @@ fn record_schema( }) } +fn point_geometry_schema() -> Value { + json!({ + "type": "object", + "additionalProperties": false, + "required": ["type", "coordinates"], + "properties": { + "type": {"const": "Point"}, + "coordinates": { + "type": "array", + "prefixItems": [ + {"type": "number", "minimum": -180, "maximum": 180}, + {"type": "number", "minimum": -90, "maximum": 90} + ], + "items": false, + "minItems": 2, + "maxItems": 2 + } + } + }) +} + pub fn representation_shacl( registry: &CompiledRegistry, resource: &CompiledResource, @@ -203,6 +267,12 @@ pub fn full_record_shacl(registry: &CompiledRegistry, resource: &CompiledResourc .properties .iter() .map(|property| property.name.clone()) + .chain( + resource + .primary_geometry + .iter() + .map(|geometry| geometry.name.clone()), + ) .collect::>(); shacl(registry, resource, &selected, true) } @@ -272,6 +342,13 @@ fn shacl( usize::from(full && property.source_required) )); } + if let Some(geometry) = selected_geometry(resource, selected) { + output.push_str(&format!( + " ;\n sh:property [ sh:path <{}> ; sh:datatype ; sh:minCount {} ; sh:maxCount 1 ]", + geometry.semantic_iri, + usize::from(full && geometry.source_required) + )); + } output.push_str(" .\n"); output } @@ -361,6 +438,16 @@ fn selected_properties<'a>( .collect() } +fn selected_geometry<'a>( + resource: &'a CompiledResource, + selected: &[String], +) -> Option<&'a CompiledPrimaryGeometry> { + resource + .primary_geometry + .as_ref() + .filter(|geometry| selected.contains(&geometry.name)) +} + #[cfg(test)] mod tests { use super::*; @@ -443,6 +530,56 @@ mod tests { } } + #[test] + fn point_geometry_is_bounded_without_geosparql_claims() { + let mut registry = registry(); + registry + .codelists + .push(codelist("state.yaml", &["ACTIVE", "RETIRED"])); + let mut resource = resource(); + resource.primary_geometry = Some(CompiledPrimaryGeometry { + name: "location".into(), + label: "Location".into(), + description: "Reviewed service location".into(), + semantic_iri: "https://example.invalid/vocab/location".into(), + source_required: true, + crs: "http://www.opengis.net/def/crs/OGC/0/CRS84".into(), + longitude_column: "longitude".into(), + latitude_column: "latitude".into(), + classification: resource.properties[0].classification.clone(), + }); + let selected = vec!["name".into(), "location".into()]; + + let schema = representation_schema( + ®istry, + &resource, + &selected, + "https://example.invalid/location.schema.json", + "https://example.invalid/location.vocabulary.jsonld", + ); + assert_eq!( + schema["properties"]["domainData"]["properties"]["location"]["properties"]["type"] + ["const"], + "Point" + ); + assert_eq!( + schema["properties"]["domainData"]["properties"]["location"]["properties"] + ["coordinates"]["prefixItems"][0]["minimum"], + -180 + ); + assert_eq!( + json_ld_context(®istry, &resource, &selected)["@context"]["location"]["@type"], + "@json" + ); + let vocabulary = local_vocabulary(®istry, &resource, &selected); + let encoded = serde_json::to_string(&vocabulary).expect("vocabulary serializes"); + assert!(encoded.contains("rdf:JSON")); + assert!(!encoded.to_ascii_lowercase().contains("geosparql")); + let shacl = representation_shacl(®istry, &resource, &selected); + assert!(shacl.contains("rdf-syntax-ns#JSON")); + assert!(!shacl.to_ascii_lowercase().contains("geosparql")); + } + fn resource() -> CompiledResource { use crate::contract::{Handling, ReviewStatus}; use crate::model::*; @@ -487,6 +624,7 @@ mod tests { provenance_ref: "review.yaml".into(), }, }], + primary_geometry: None, disclosure_profiles: Vec::new(), operations: Vec::new(), column_accounting: Vec::new(), diff --git a/crates/registry-relay-v2/src/sqlite_runtime.rs b/crates/registry-relay-v2/src/sqlite_runtime.rs index 968c6ff01..52eac7c78 100644 --- a/crates/registry-relay-v2/src/sqlite_runtime.rs +++ b/crates/registry-relay-v2/src/sqlite_runtime.rs @@ -66,6 +66,37 @@ pub struct OperationQuery { pub row_authority: Option, pub after_order: Option>, pub fetch_limit: Option, + pub bbox: Option, +} + +/// A validated CRS84 point bounding box. Runtime callers construct this only +/// after applying the operation's compiled range and span limits. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PointBbox { + pub west: f64, + pub south: f64, + pub east: f64, + pub north: f64, +} + +impl PointBbox { + fn is_valid(self) -> bool { + [self.west, self.south, self.east, self.north] + .into_iter() + .all(f64::is_finite) + && (-180.0..=180.0).contains(&self.west) + && (-180.0..=180.0).contains(&self.east) + && (-90.0..=90.0).contains(&self.south) + && (-90.0..=90.0).contains(&self.north) + && self.west <= self.east + && self.south <= self.north + } + + fn is_within(self, spatial: &crate::model::CompiledSpatialBboxQuery) -> bool { + self.is_valid() + && self.east - self.west <= f64::from(spatial.maximum_longitude_span_degrees) + && self.north - self.south <= f64::from(spatial.maximum_latitude_span_degrees) + } } #[derive(Clone, Debug)] @@ -370,6 +401,11 @@ fn result_columns( } fn column_type(resource: &CompiledResource, column: &str) -> ColumnType { + if resource.primary_geometry.as_ref().is_some_and(|geometry| { + geometry.longitude_column == column || geometry.latitude_column == column + }) { + return ColumnType::Number; + } resource .properties .iter() @@ -408,6 +444,24 @@ fn list_sql( quote_identifier(&filter.source_column) )); } + if let Some(spatial) = &operation.query.spatial_bbox { + for name in [ + "bbox_present", + "bbox_west", + "bbox_south", + "bbox_east", + "bbox_north", + ] { + parameters.push(parameter(name)); + } + predicates.push(format!( + "(:bbox_present = 0 OR ({} >= :bbox_south AND {} <= :bbox_north AND {} >= :bbox_west AND {} <= :bbox_east))", + quote_identifier(&spatial.latitude_column), + quote_identifier(&spatial.latitude_column), + quote_identifier(&spatial.longitude_column), + quote_identifier(&spatial.longitude_column), + )); + } add_row_authority(representation, parameters, &mut predicates); parameters.push(parameter("cursor_present")); let keyset = keyset_predicate(&operation.query.order_by, parameters); @@ -558,6 +612,35 @@ fn bind_operation_values( ); values.insert(format!("filter_{index}"), value.unwrap_or(Value::Null)); } + match (&operation.query.spatial_bbox, query.bbox) { + (Some(spatial), bbox) => { + if bbox.is_some_and(|value| !value.is_within(spatial)) { + return Err(SqliteRuntimeError::InvalidPlan); + } + values.insert( + "bbox_present".into(), + Value::Integer(i64::from(bbox.is_some())), + ); + values.insert( + "bbox_west".into(), + bbox.map_or(Value::Null, |value| Value::Number(value.west)), + ); + values.insert( + "bbox_south".into(), + bbox.map_or(Value::Null, |value| Value::Number(value.south)), + ); + values.insert( + "bbox_east".into(), + bbox.map_or(Value::Null, |value| Value::Number(value.east)), + ); + values.insert( + "bbox_north".into(), + bbox.map_or(Value::Null, |value| Value::Number(value.north)), + ); + } + (None, None) => {} + (None, Some(_)) => return Err(SqliteRuntimeError::InvalidPlan), + } let after = query.after_order.unwrap_or_default(); if !after.is_empty() && after.len() != operation.query.order_by.len() { return Err(SqliteRuntimeError::InvalidPlan); @@ -580,6 +663,9 @@ fn bind_operation_values( ); } OperationKind::Read => { + if query.bbox.is_some() { + return Err(SqliteRuntimeError::InvalidPlan); + } values.insert( "record_identifier".into(), Value::String( @@ -590,6 +676,9 @@ fn bind_operation_values( ); } OperationKind::Lookup { .. } => { + if query.bbox.is_some() { + return Err(SqliteRuntimeError::InvalidPlan); + } if query.selectors.len() != operation.query.selectors.len() { return Err(SqliteRuntimeError::InvalidPlan); } @@ -620,3 +709,70 @@ fn bind_operation_values( } Ok(values) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn point_bbox_validation_is_numeric_and_crs84_bounded() { + assert!(PointBbox { + west: 100.0, + south: -20.0, + east: 101.0, + north: -16.0, + } + .is_valid()); + assert!(!PointBbox { + west: f64::NAN, + south: 0.0, + east: 1.0, + north: 1.0, + } + .is_valid()); + assert!(!PointBbox { + west: -181.0, + south: 0.0, + east: 1.0, + north: 1.0, + } + .is_valid()); + assert!(!PointBbox { + west: 0.0, + south: 2.0, + east: 1.0, + north: 1.0, + } + .is_valid()); + } + + #[test] + fn point_bbox_refuses_dateline_crossing() { + let bbox = PointBbox { + west: 100.0, + south: 10.0, + east: 101.0, + north: 11.0, + }; + assert!(bbox.is_valid()); + assert!(bbox.is_within(&crate::model::CompiledSpatialBboxQuery { + longitude_column: "longitude".into(), + latitude_column: "latitude".into(), + maximum_longitude_span_degrees: 1, + maximum_latitude_span_degrees: 1, + })); + assert!(!bbox.is_within(&crate::model::CompiledSpatialBboxQuery { + longitude_column: "longitude".into(), + latitude_column: "latitude".into(), + maximum_longitude_span_degrees: 1, + maximum_latitude_span_degrees: 0, + })); + assert!(!PointBbox { + west: 177.0, + south: -20.0, + east: -178.0, + north: -16.0, + } + .is_valid()); + } +} diff --git a/crates/registry-relay-v2/tests/acceptance_http.rs b/crates/registry-relay-v2/tests/acceptance_http.rs index 193ca034c..53e324bb7 100644 --- a/crates/registry-relay-v2/tests/acceptance_http.rs +++ b/crates/registry-relay-v2/tests/acceptance_http.rs @@ -11,7 +11,9 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use axum::body::{to_bytes, Body}; use bytes::Bytes; use futures::stream; -use http::header::{AUTHORIZATION, CACHE_CONTROL, CONTENT_TYPE, ETAG, VARY}; +use http::header::{ + ACCEPT, AUTHORIZATION, CACHE_CONTROL, CONTENT_TYPE, ETAG, IF_NONE_MATCH, LINK, VARY, +}; use http::{HeaderMap, HeaderName, HeaderValue, Method, Request, StatusCode}; use jsonschema::{Draft, JSONSchema}; use oxjsonld::JsonLdParser; @@ -27,7 +29,7 @@ use registry_platform_sqlite::{ use registry_platform_testing::{ fixtures, oidc_verifier_config, sign_ed25519_compact_jwt, MockIdp, }; -use registry_relay_v2::artifacts::generate_artifacts; +use registry_relay_v2::artifacts::{generate_artifacts, ArtifactSet}; use registry_relay_v2::audit::RelayAudit; use registry_relay_v2::auth::RelayAuthenticator; use registry_relay_v2::compiler::{ @@ -36,8 +38,10 @@ use registry_relay_v2::compiler::{ }; use registry_relay_v2::contract::{RegistryContract, RelayRuntime}; use registry_relay_v2::fixture_contract::{ - parse_journey, FixtureAuthorization as AuthorizationFixture, FixtureJourney as Journey, - FixtureMethod, FixtureStep as JourneyStep, + parse_journey, FixtureAuthorization as AuthorizationFixture, + FixtureExpectation as JourneyExpectation, FixtureGeoJsonRoot as JourneyGeoJsonRoot, + FixtureGeometryType as JourneyGeometryType, FixtureJourney as Journey, FixtureMethod, + FixtureRepresentationProfile as JourneyRepresentationProfile, FixtureStep as JourneyStep, }; use registry_relay_v2::identification::{ parse_classification_review_yaml, render_classification_review_yaml, @@ -68,6 +72,7 @@ struct ResponseContractCoverage { struct ProjectHarness { app: axum::Router, service: Arc, + artifacts: Arc, contract: RegistryContract, runtime: RelayRuntime, database: PathBuf, @@ -608,6 +613,177 @@ async fn audit_terminal_failure_discards_held_record_bytes() { ); } +#[tokio::test] +async fn spatial_representations_validate_and_keep_distinct_cache_identities() { + let harness = ProjectHarness::open("business-registry").await; + let path = "/v2/resources/registered-premises/records/PREM-SYNTH-0001"; + let (json_headers, json) = successful_get(&harness, path, None, None).await; + let (json_ld_headers, json_ld) = + successful_get(&harness, path, Some("application/ld+json"), None).await; + let (rfc_headers, rfc) = successful_get( + &harness, + &format!("{path}?profile=rfc7946"), + Some("application/geo+json"), + None, + ) + .await; + let (json_fg_headers, json_fg) = successful_get( + &harness, + &format!("{path}?profile=jsonfg"), + Some("application/geo+json"), + None, + ) + .await; + + assert_eq!(normalized_records(&json), normalized_records(&json_ld)); + let context: Value = serde_json::from_slice( + &harness + .artifacts + .get( + "artifacts/registered-premises--read--representation-public-premises.context.jsonld", + ) + .expect("generated spatial JSON-LD context") + .content, + ) + .expect("context parses"); + assert_eq!( + json_ld.get("@context").and_then(Value::as_str), + Some( + "https://business.example.invalid/v2/artifacts/registered-premises--read--representation-public-premises-context", + ) + ); + assert_eq!( + context.pointer("/@context/location/@type"), + Some(&json!("@json")) + ); + assert_eq!( + context.pointer("/@context/location/@nest"), + Some(&json!("domainData")) + ); + + let schema: Value = serde_json::from_slice( + &harness + .artifacts + .get( + "artifacts/registered-premises--read--representation-public-premises.geojson.schema.json", + ) + .expect("generated spatial response schema") + .content, + ) + .expect("schema parses"); + let validator = jsonschema::JSONSchema::options() + .with_draft(jsonschema::Draft::Draft202012) + .compile(&schema) + .expect("generated spatial response schema compiles"); + assert!( + validator.is_valid(&rfc), + "RFC 7946 response matches its schema" + ); + assert!( + validator.is_valid(&json_fg), + "JSON-FG response matches the governed GeoJSON schema" + ); + assert_eq!( + json_fg + .get("conformsTo") + .and_then(Value::as_array) + .map(|values| values.iter().filter_map(Value::as_str).collect()), + Some(BTreeSet::from([ + "http://www.opengis.net/spec/json-fg-1/1.0/conf/core", + "http://www.opengis.net/spec/json-fg-1/1.0/conf/types-schemas", + ])) + ); + assert_eq!( + json_fg.get("featureType"), + Some(&json!("registered-premises")) + ); + + let etags = [ + &json_headers, + &json_ld_headers, + &rfc_headers, + &json_fg_headers, + ] + .into_iter() + .map(|headers| { + headers + .get(ETAG) + .and_then(|value| value.to_str().ok()) + .expect("snapshot representation has an ETag") + }) + .collect::>(); + assert_eq!(etags.len(), 4, "each exact representation has its own ETag"); + + let json_fg_etag = json_fg_headers + .get(ETAG) + .and_then(|value| value.to_str().ok()) + .expect("JSON-FG ETag"); + let response = harness + .app + .clone() + .oneshot(get_request( + &format!("{path}?profile=jsonfg"), + Some("application/geo+json"), + Some(json_fg_etag), + )) + .await + .expect("router responds"); + assert_eq!(response.status(), StatusCode::NOT_MODIFIED); + assert_eq!( + response + .headers() + .get(ETAG) + .and_then(|value| value.to_str().ok()), + Some(json_fg_etag) + ); + assert_eq!( + response + .headers() + .get(LINK) + .and_then(|value| value.to_str().ok()), + Some("; rel=\"profile\"") + ); + assert!(to_bytes(response.into_body(), 1024) + .await + .expect("304 body reads") + .is_empty()); +} + +#[tokio::test] +async fn spatial_terminal_audit_failure_discards_held_feature_bytes() { + let sink = Arc::new(ControlledAuditSink::new(2)); + let harness = ProjectHarness::open_with_audit( + "business-registry", + Some(Arc::clone(&sink) as Arc), + ) + .await; + let response = harness + .app + .oneshot(get_request( + "/v2/resources/registered-premises/records/PREM-SYNTH-0001?profile=jsonfg", + Some("application/geo+json"), + None, + )) + .await + .expect("router responds"); + let body = response_body(response, StatusCode::SERVICE_UNAVAILABLE).await; + assert_eq!( + body.get("code").and_then(Value::as_str), + Some("audit.unavailable") + ); + let wire = serde_json::to_string(&body).expect("problem serializes"); + for protected in [ + "PREM-SYNTH-0001", + "Orchard cooperative market", + "100.0", + "13.0", + "Feature", + ] { + assert!(!wire.contains(protected)); + } + assert_eq!(sink.writes(), 2); +} + #[tokio::test] async fn real_jwt_path_rejects_malformed_audience_time_and_expired_tokens() { let harness = ProjectHarness::open("social-assistance").await; @@ -1136,6 +1312,47 @@ fn request_with_bearer( request } +fn get_request(uri: &str, accept: Option<&str>, if_none_match: Option<&str>) -> Request { + let mut request = Request::builder() + .uri(uri) + .body(Body::empty()) + .expect("request builds"); + if let Some(accept) = accept { + request.headers_mut().insert( + ACCEPT, + HeaderValue::from_str(accept).expect("Accept header is valid"), + ); + } + if let Some(etag) = if_none_match { + request.headers_mut().insert( + IF_NONE_MATCH, + HeaderValue::from_str(etag).expect("If-None-Match header is valid"), + ); + } + request +} + +async fn successful_get( + harness: &ProjectHarness, + uri: &str, + accept: Option<&str>, + if_none_match: Option<&str>, +) -> (HeaderMap, Value) { + let response = harness + .app + .clone() + .oneshot(get_request(uri, accept, if_none_match)) + .await + .expect("router responds"); + assert_eq!(response.status(), StatusCode::OK); + let headers = response.headers().clone(); + let body = to_bytes(response.into_body(), 8 * 1024 * 1024) + .await + .expect("bounded response reads"); + let document = serde_json::from_slice(&body).expect("response is JSON"); + (headers, document) +} + async fn assert_problem_code(response: http::Response, status: StatusCode, code: &str) { let body = response_body(response, status).await; assert_eq!(body.get("code").and_then(Value::as_str), Some(code)); @@ -1210,6 +1427,7 @@ fn assert_expectations( assert_eq!( document .get("items") + .or_else(|| document.get("features")) .and_then(Value::as_array) .map(Vec::len), Some(count as usize), @@ -1302,13 +1520,15 @@ fn assert_expectations( } if let Some(identifier) = &step.expect.record_identifier { assert_eq!( - document - .pointer("/data/recordIdentifier") + records + .first() + .and_then(|record| record.get("recordIdentifier")) .and_then(Value::as_str), Some(identifier.as_str()), "{label} record identifier" ); } + assert_geojson_expectations(&label, &step.expect, headers, &document); if let Some(cache) = &step.expect.cache { match cache.as_str() { "public-snapshot-revalidation" => { @@ -1363,22 +1583,162 @@ fn assert_expectations( } } +fn response_geometries(document: &Value) -> Vec> { + if document.get("type").and_then(Value::as_str) == Some("Feature") { + vec![document.get("geometry")] + } else if document.get("type").and_then(Value::as_str) == Some("FeatureCollection") { + document + .get("features") + .and_then(Value::as_array) + .map_or_else(Vec::new, |features| { + features + .iter() + .map(|feature| feature.get("geometry")) + .collect() + }) + } else { + Vec::new() + } +} + +fn assert_geojson_expectations( + label: &str, + expectation: &JourneyExpectation, + headers: &HeaderMap, + document: &Value, +) { + if let Some(expected) = expectation.geo_json_root { + let expected = match expected { + JourneyGeoJsonRoot::Feature => "Feature", + JourneyGeoJsonRoot::FeatureCollection => "FeatureCollection", + }; + assert_eq!( + document.get("type").and_then(Value::as_str), + Some(expected), + "{label} GeoJSON root" + ); + } + if let Some(expected) = expectation.geometry_type { + let geometries = response_geometries(document); + assert!( + !geometries.is_empty(), + "{label} must contain geometry members" + ); + for geometry in geometries { + match expected { + JourneyGeometryType::Point => assert_eq!( + geometry + .and_then(|value| value.get("type")) + .and_then(Value::as_str), + Some("Point"), + "{label} geometry type" + ), + JourneyGeometryType::Null => assert!( + geometry.is_some_and(Value::is_null), + "{label} geometry must be explicit null" + ), + } + } + } + let Some(profile) = expectation.representation_profile else { + return; + }; + assert_eq!( + headers + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()), + Some("application/geo+json"), + "{label} GeoJSON content type" + ); + let (uri, conforms_to) = match profile { + JourneyRepresentationProfile::Rfc7946 => { + ("http://www.opengis.net/def/profile/OGC/0/rfc7946", None) + } + JourneyRepresentationProfile::JsonFg => ( + "http://www.opengis.net/def/profile/OGC/0/jsonfg", + Some(BTreeSet::from([ + "http://www.opengis.net/spec/json-fg-1/1.0/conf/core", + "http://www.opengis.net/spec/json-fg-1/1.0/conf/types-schemas", + ])), + ), + }; + let link = format!("<{uri}>; rel=\"profile\""); + assert_eq!( + headers.get(LINK).and_then(|value| value.to_str().ok()), + Some(link.as_str()), + "{label} GeoJSON profile link" + ); + match conforms_to { + Some(expected) => assert_eq!( + document + .get("conformsTo") + .and_then(Value::as_array) + .map(|values| values.iter().filter_map(Value::as_str).collect()), + Some(expected), + "{label} JSON-FG conformance" + ), + None => { + assert!( + document.get("conformsTo").is_none(), + "{label} RFC 7946 response must not claim JSON-FG conformance" + ); + assert!( + document.get("featureType").is_none(), + "{label} RFC 7946 response must not contain JSON-FG feature type" + ); + } + } +} + fn normalized_records(document: &Value) -> Vec { + let geometries = response_geometries(document); response_records(document) .into_iter() - .map(|record| { + .enumerate() + .map(|(index, record)| { let mut record = record.clone(); + let mut geometry = geometries + .get(index) + .and_then(|geometry| *geometry) + .cloned() + .unwrap_or(Value::Null); + if geometry.is_null() { + if let Some(domain) = record.get_mut("domainData").and_then(Value::as_object_mut) { + let geometry_name = domain.iter().find_map(|(name, value)| { + (value.get("type").and_then(Value::as_str) == Some("Point") + && value.get("coordinates").is_some()) + .then(|| name.clone()) + }); + if let Some(name) = geometry_name { + geometry = domain.remove(&name).unwrap_or(Value::Null); + } + } + } if let Some(object) = record.as_object_mut() { object.remove("@id"); object.remove("@type"); } - record + serde_json::json!({"record": record, "geometry": geometry}) }) .collect() } fn response_records(document: &Value) -> Vec<&Value> { - if let Some(record) = document.get("data") { + if document.get("type").and_then(Value::as_str) == Some("Feature") { + document + .get("properties") + .map_or_else(Vec::new, |record| vec![record]) + } else if document.get("type").and_then(Value::as_str) == Some("FeatureCollection") { + document + .get("features") + .and_then(Value::as_array) + .map_or_else(Vec::new, |features| { + features + .iter() + .filter_map(|feature| feature.get("properties")) + .collect() + }) + } else if let Some(record) = document.get("data") { vec![record] } else { document @@ -1406,7 +1766,7 @@ fn validate_response_contracts( .and_then(|value| value.split(';').next()) .expect("Record response has a content type"); let json_ld = match media_type { - "application/json" => false, + "application/json" | "application/geo+json" => false, "application/ld+json" => true, _ => panic!( "{project}/{} returned an unsupported Record media type", @@ -1659,6 +2019,27 @@ fn validate_json_ld_graph( .as_object() .expect("Record domainData is an object"); for property_name in domain_data.keys() { + if let Some(geometry) = resource + .primary_geometry + .as_ref() + .filter(|geometry| geometry.name == *property_name) + { + assert!(representation.selectable_properties.contains(property_name)); + let datatype = "http://www.w3.org/1999/02/22-rdf-syntax-ns#JSON"; + assert_typed_quad( + &quads, + subject, + &geometry.semantic_iri, + datatype, + project, + &step.id, + ); + assert!(shacl.contains(&format!( + "sh:path <{}> ; sh:datatype <{datatype}>", + geometry.semantic_iri + ))); + continue; + } let property = resource .properties .iter() @@ -1916,7 +2297,7 @@ impl ProjectHarness { }; let service = Arc::new(RelayService::new( compiled, - artifacts, + Arc::clone(&artifacts), sqlite, authenticator, audit, @@ -1942,6 +2323,7 @@ impl ProjectHarness { Self { app: router(Arc::clone(&service)), service, + artifacts, contract, runtime, database, diff --git a/crates/registry-relay-v2/tests/representation_http.rs b/crates/registry-relay-v2/tests/representation_http.rs index 8563afee4..a2d435f0a 100644 --- a/crates/registry-relay-v2/tests/representation_http.rs +++ b/crates/registry-relay-v2/tests/representation_http.rs @@ -932,6 +932,7 @@ fn compiled_registry(fingerprint: String) -> CompiledRegistry { source: SOURCE.into(), view: "relay_records".into(), filters: Vec::new(), + spatial_bbox: None, selectors: Vec::new(), order_by: vec!["record_id".into()], allow_unfiltered: true, @@ -953,6 +954,7 @@ fn compiled_registry(fingerprint: String) -> CompiledRegistry { source: SOURCE.into(), view: "relay_records".into(), filters: Vec::new(), + spatial_bbox: None, selectors: Vec::new(), order_by: Vec::new(), allow_unfiltered: false, @@ -973,6 +975,7 @@ fn compiled_registry(fingerprint: String) -> CompiledRegistry { source: SOURCE.into(), view: "relay_records".into(), filters: Vec::new(), + spatial_bbox: None, selectors: vec![CompiledSelector { name: "lookupKey".into(), source_column: "lookup_key".into(), @@ -1036,6 +1039,7 @@ fn compiled_registry(fingerprint: String) -> CompiledRegistry { .into(), }, properties: properties(), + primary_geometry: None, disclosure_profiles: vec![ disclosure( "public-disclosure", diff --git a/products/relay-v2/CONCEPT.md b/products/relay-v2/CONCEPT.md index 3893fbd91..120d37f33 100644 --- a/products/relay-v2/CONCEPT.md +++ b/products/relay-v2/CONCEPT.md @@ -35,7 +35,7 @@ An adopter supplies: Relay produces: - a read-only registry API over explicitly declared resources; -- ordinary JSON and equivalent JSON-LD representations; +- ordinary JSON, equivalent JSON-LD, and opt-in GeoJSON point representations; - OpenAPI, JSON Schema, SHACL shapes, contexts, codelists, and discovery metadata; - derived Consultation capabilities and a concise standards-alignment note; - generated local semantics when the adopter has no existing vocabulary work; @@ -376,7 +376,7 @@ token and cannot choose an order or replay a cursor across representations. Single-record reads and resolved lookups use `{data, meta}`. `data` contains the Registry Core context and `domainData`. `fields` is a documented Relay -extension: a non-empty, duplicate-free comma-separated list of public property +extension: a non-empty, duplicate-free comma-separated list of published property keys. A property key is the contract's URL-safe camelCase name, not a source column or semantic IRI. Exactly one `fields` parameter is accepted; empty members, whitespace, repeats, and duplicate keys are invalid. It only narrows @@ -396,6 +396,40 @@ response includes `Vary: Accept, Authorization` so an anonymous `200` cannot satisfy a request carrying an invalid bearer. Non-public and unversioned-live responses are `no-store` and emit no ETag. +### Bounded spatial point profile + +An operation may opt in to a single classified `primaryGeometry`: a validated +GeoJSON Point assembled from reviewed SQLite longitude and latitude columns in +WGS 84 longitude-latitude order (`CRS84`). The geometry is a governed, +selectable domain field, subject to the same disclosure profile, +source-required validation, requester minimization, access, audit, and cache +posture as every other published field. Requesting GeoJSON never grants a +different right or a wider property set. + +An opted list may declare bounded exact point `bbox` search. All four CRS84 +coordinates must be finite and in range, use increasing bounds, avoid +antimeridian crossing, and remain within publisher-declared longitude and +latitude spans. Relay executes inclusive point containment against the reviewed +coordinate columns. Because `bbox` is a collection-query selector, its primary +geometry must be classified `privacy: non-personal`; personal locations require +a different bounded consultation design. A cursor binds bbox, the selected +governed representation, response format, and GeoJSON profile as well as the +ordinary list context. + +`application/geo+json` is available only when the resource declares a primary +geometry and the exact selected governed representation discloses it. It +returns an RFC 7946 `Feature` or `FeatureCollection`. Feature `properties` +carry Registry Core and the selected non-spatial domain fields, while +`geometry` carries the selected primary geometry; together they preserve the +same governed disclosure as ordinary JSON. `profile=rfc7946` is the default; +`profile=jsonfg` adds JSON-FG profile metadata while retaining valid GeoJSON +core. A caller may select fewer fields, including the geometry, in which case +the GeoJSON feature geometry is `null`. `representation=` still selects the +finite access and disclosure contract; `Accept` and `profile` select only its +wire format. This profile intentionally excludes +generic geometry ingestion, OGC API Features routes, CQL2, EDR, tiles, +reprojection, and spatial joins. + The packaged deployment contains the full generated OpenAPI 3.1 contract. The unauthenticated `/openapi.json` endpoint serves a deterministic safe public projection and omits protected resources, selector shapes, and operator-only @@ -604,7 +638,11 @@ Relay V2 is not: - a multi-source analytics or interoperability-protocol suite; - an extension, mode, or command group of the existing `registryctl`. -PostgreSQL and other source adapters, GeoJSON and SpatiaLite, richer semantic profiles, and additional registry protocols are later profiles. The initial architecture should leave room for source adapters, but version one should not introduce a generic storage trait before a second adapter proves the abstraction. +PostgreSQL and other source adapters, SpatiaLite, GeoPackage decoding, richer +semantic profiles, and additional registry protocols are later profiles. The +initial architecture keeps the point-source binding closed and explicit rather +than introducing a generic storage trait before a second adapter proves the +abstraction. ## Reuse from `registry-platform-*` @@ -717,7 +755,7 @@ The first coherent Relay V2 release should contain: 13. tamper-evident attempt, refusal, and pre-release audit gates; 14. fixture, schema-drift, contract-drift, change-impact, and security-safeguard checks. -This is enough to establish Relay as a governed semantic registry publisher rather than a database API. Additional storage engines, spatial data, richer policy, and protocol profiles can then be judged against that identity. +This is enough to establish Relay as a governed semantic registry publisher rather than a database API. Additional storage engines, richer spatial data, policy, and protocol profiles can then be judged against that identity. ## Definition of Done and coequal acceptance registries @@ -729,7 +767,7 @@ instantiated one-Registry services over real loopback HTTP, plus one packaged real-process start, request, stop, and restart smoke: - a sensitive social assistance registry proves exact-lookup-only consultation, trusted purpose, row binding, protected classification, and live SQLite; -- a public business registry proves deterministic list and identifier read, predefined filters, public semantic alignment, snapshot reproducibility, and caching; +- a public business registry proves deterministic list and identifier read, predefined filters, bounded public premises-point search, public semantic alignment, snapshot reproducibility, and caching; - a protected civil-event registry proves separate read and lookup scopes, operation-specific disclosures, optional conforming Mint tokens, unversioned-live truthfulness, and the ordinary protected-source boundary a @@ -752,7 +790,7 @@ shape. The generated schema makes their constraints precise. - Relay owns a strict registry contract. A Registry Manifest projection is a later portability artifact, not the Relay execution contract. - One contract and process serves one Registry, which may contain several resources. - Registry Core context is mandatory and requester field selection can narrow only `domainData`. -- Lists use `pageSize`, `cursor`, `items`, and `pageInfo.nextCursor`; predefined filters are direct camelCase equality parameters. +- Lists use `pageSize`, `cursor`, `items`, and `pageInfo.nextCursor`; predefined filters are direct camelCase equality parameters, and an opted point resource may accept bounded exact `bbox`. - Named exact lookup remains a bounded POST action and maps to constrained Consultation Search, not Record Match. - Each operation has finite reviewed representations, an explicit sole default, and representation-owned access plus disclosure. Dynamic, caller-derived @@ -772,5 +810,5 @@ shape. The generated schema makes their constraints precise. - Registry Manifest, DPV, safeguards, and machine-readable GovStack alignment projections; - formal GovStack conformance or a compatibility flag translating wire conventions; - a Digital Registries family beyond Consultation; -- additional sources, GeoJSON, and SpatiaLite; +- additional sources, generic geometry, GeoPackage decoding, and SpatiaLite; - dynamic masking, a general PDP, write operations, notification, access-history publication, and response signing. diff --git a/products/relay-v2/CONFIGURATION-EXAMPLES.md b/products/relay-v2/CONFIGURATION-EXAMPLES.md index 35df3f7a5..289e0856f 100644 --- a/products/relay-v2/CONFIGURATION-EXAMPLES.md +++ b/products/relay-v2/CONFIGURATION-EXAMPLES.md @@ -472,6 +472,83 @@ What this example must prove: - the captured snapshot digest and schema fingerprint make responses reproducible and strongly cacheable by revision; - capability discovery derives `consultation.list` and `consultation.retrieve` and no other family pattern. +### Point location variant: governed premises search + +The following second resource can sit beside the business resource above. It is +not a generic map service or a spatial database API. The reviewed source view +owns the two numeric carrier columns; Relay validates and reconstructs one +CRS84 GeoJSON Point only after its complete Record is safe to disclose. The +geometry has a normal classification and enters the maximum disclosure profile, +so `fields` can remove it but cannot add it. `bbox` is a fixed publisher-owned +query shape, not an expression language. A bbox-enabled primary geometry must +be classified `privacy: non-personal`, even for a protected list. Its maximum +spans keep an anonymous public search local and bounded. + +```yaml + - id: registered-premises + title: Registered premises + description: Public point locations of reviewed registered business premises + semanticClass: local:RegisteredPremises + source: {source: companies, view: relay_registered_premises} + classificationDefaults: {privacy: non-personal, institutional: public, handling: public, status: reviewed} + recordContext: + recordIdentifier: {sourceColumn: premises_identifier} + revisionIdentifier: {sourceColumn: record_revision} + lifecycleState: {sourceColumn: lifecycle_state, codelist: codelists/record-lifecycle.yaml} + recordedAt: {sourceColumn: recorded_at} + primaryGeometry: + name: location + label: Premises location + description: Reviewed premises point in CRS84 longitude-latitude order + semanticTerm: local:location + sourceRequired: true + crs: http://www.opengis.net/def/crs/OGC/0/CRS84 + source: {longitudeColumn: longitude, latitudeColumn: latitude} + classification: {privacy: non-personal, institutional: public, handling: public, status: reviewed} + properties: + premisesIdentifier: + sourceColumn: premises_identifier + type: string + sourceRequired: true + semanticTerm: local:premisesIdentifier + label: Premises identifier + description: Stable public identifier for one premises Record + businessRegistrationNumber: + sourceColumn: business_registration_number + type: string + sourceRequired: true + semanticTerm: local:businessRegistrationNumber + label: Business registration number + description: Registered business associated with the premises + disclosureProfiles: + public-premises: {properties: [premisesIdentifier, businessRegistrationNumber, location]} + operations: + list: + defaultRepresentation: public-premises + representations: + public-premises: {access: public, disclosureProfile: public-premises} + allowUnfiltered: false + spatialQuery: + bbox: {maximumLongitudeSpanDegrees: 2, maximumLatitudeSpanDegrees: 2} + orderBy: [premisesIdentifier] + pagination: {defaultPageSize: 50, maximumPageSize: 200} + read: + defaultRepresentation: public-premises + representations: + public-premises: {access: public, disclosureProfile: public-premises} +``` + +The `public-premises` governed representation is the access and maximum +disclosure decision. JSON and JSON-LD are always available for it; because its +disclosure profile includes `location`, `Accept: application/geo+json` is also +available and returns RFC 7946 by default. A client may ask for +`profile=jsonfg` to receive JSON-FG conformance metadata. In both forms, +Feature `properties` plus the separately selected `geometry` carry the same +governed disclosure as ordinary JSON. A query such as +`bbox=100,13,101,14` includes only points within that closed inclusive extent; +antimeridian-crossing boxes, arbitrary CRS requests, CQL2, tiles, EDR, spatial +joins, and dynamic SpatiaLite functions are not part of this profile. + ## Example 3: civil-event registry This registry is CRVS-shaped but the runtime remains event-domain neutral. It has no collection-list operation. Authorized registrars may read a known opaque event identifier under one scope and disclosure profile. A verification client may perform only a named exact lookup under a different scope and smaller disclosure profile. It uses an ordinary external issuer for the core journey. Registry Mint may replace that issuer later when it emits the same standard token profile. @@ -780,6 +857,9 @@ resources[].operations.list.pagination resources[].operations.list.pagination.defaultPageSize resources[].operations.list.pagination.maximumPageSize resources[].operations.list.representations +resources[].operations.list.representations.public-premises +resources[].operations.list.representations.public-premises.access +resources[].operations.list.representations.public-premises.disclosureProfile resources[].operations.list.representations.public-register resources[].operations.list.representations.public-register.access resources[].operations.list.representations.public-register.disclosureProfile @@ -789,6 +869,10 @@ resources[].operations.list.representations.registrar.access.authorityRowBinding resources[].operations.list.representations.registrar.access.purpose resources[].operations.list.representations.registrar.access.scope resources[].operations.list.representations.registrar.disclosureProfile +resources[].operations.list.spatialQuery +resources[].operations.list.spatialQuery.bbox +resources[].operations.list.spatialQuery.bbox.maximumLatitudeSpanDegrees +resources[].operations.list.spatialQuery.bbox.maximumLongitudeSpanDegrees resources[].operations.lookups resources[].operations.lookups[] resources[].operations.lookups[].defaultRepresentation @@ -850,6 +934,9 @@ resources[].operations.lookups[].requestBody.selectors.*.type resources[].operations.read resources[].operations.read.defaultRepresentation resources[].operations.read.representations +resources[].operations.read.representations.public-premises +resources[].operations.read.representations.public-premises.access +resources[].operations.read.representations.public-premises.disclosureProfile resources[].operations.read.representations.public-register resources[].operations.read.representations.public-register.access resources[].operations.read.representations.public-register.disclosureProfile @@ -864,6 +951,21 @@ resources[].operations.read.representations.registrar.access.purpose.allowed[] resources[].operations.read.representations.registrar.access.purpose.claim resources[].operations.read.representations.registrar.access.scope resources[].operations.read.representations.registrar.disclosureProfile +resources[].primaryGeometry +resources[].primaryGeometry.classification +resources[].primaryGeometry.classification.handling +resources[].primaryGeometry.classification.institutional +resources[].primaryGeometry.classification.privacy +resources[].primaryGeometry.classification.status +resources[].primaryGeometry.crs +resources[].primaryGeometry.description +resources[].primaryGeometry.label +resources[].primaryGeometry.name +resources[].primaryGeometry.semanticTerm +resources[].primaryGeometry.source +resources[].primaryGeometry.source.latitudeColumn +resources[].primaryGeometry.source.longitudeColumn +resources[].primaryGeometry.sourceRequired resources[].processingDescriptions resources[].processingDescriptions[] resources[].processingDescriptions[].dpvProfileRef diff --git a/products/relay-v2/DEFINITION-OF-DONE.md b/products/relay-v2/DEFINITION-OF-DONE.md index 6af4ae543..c057505af 100644 --- a/products/relay-v2/DEFINITION-OF-DONE.md +++ b/products/relay-v2/DEFINITION-OF-DONE.md @@ -16,7 +16,7 @@ Relay V2 is done only when every required row below passes on the same revision. The social, business, and civil-event registries are coequal acceptance definitions. None is the architectural seed, a privileged demo, or a later generality check. Production code, public schemas, routes, and CLI behavior remain registry-domain neutral. -No required behavior may remain as a stub, TODO, undocumented manual step, disabled test, or follow-up issue. Additional storage engines, SpatiaLite, GeoJSON, general policy evaluation, response signing, dynamic masking, and other future profiles are outside this Definition of Done. +No required behavior may remain as a stub, TODO, undocumented manual step, disabled test, or follow-up issue. Additional storage engines, SpatiaLite, GeoPackage decoding, generic geometry, general policy evaluation, response signing, dynamic masking, and other future profiles are outside this Definition of Done. ## Coequal acceptance definitions @@ -69,7 +69,7 @@ prove in-process resource isolation without adding a fourth deployment project. | Change impact and safeguards | A contract diff identifies new properties, wider operations or filters, relaxed classification, changed disclosure profiles, removed row bindings, expanded scopes or purposes, changed metadata visibility, source-view changes, and semantic mapping changes. Each applicable DPI safeguard is linked to a concrete mechanism, enforcement point, negative test, evidence artifact, and named institutional responsibility. No certification claim is generated. | | Operability | Unauthenticated `/health` reports only liveness and `/ready` reports only ability to serve the compiled Registry, each as minimal `application/json` on `200`, `no-store`, and safe Registry Stack Problem `503` on failure. Neither exposes Registry or source details. Startup, shutdown, bounded concurrency, audit durability, issuer-key refresh, source unavailability, schema drift, and live publisher replacement have documented and tested behavior. Relay emits structured value-free lifecycle logs and bounded request outcomes using only a fixed method class, route template, status, latency, and trace identifier. Version one has no `/metrics` route or in-process metrics registry; operators derive aggregate metrics outside Relay from these logs and durable audit without protected values or high-cardinality subject labels. | | Verification evidence | Focused positive, negative, boundary, and non-disclosure tests pass for every security-sensitive behavior. Formatting, package check, Clippy with warnings denied, package tests, workspace tests, dependency policy, contract drift, exposure inventory, source neutrality, config-key-path, and reproducible-generation gates pass on one revision. CI path selection is itself tested for every new owning path. | -| Stop boundary | Version 1 contains no generic storage trait before a second adapter, SpatiaLite or GeoJSON path, general search language, fuzzy or Record Match behavior, dynamic masking, caller-dependent maximum entitlement profile, PDP, consent workflow, response signing, credential lifecycle, multi-source analytics, runtime vocabulary fetch, RDF store, SPARQL, hot reload, write API, registry administration, formal GovStack compatibility mode, or compatibility work in `registryctl`. | +| Stop boundary | Version 1 contains no generic storage trait before a second adapter, SpatiaLite, GeoPackage decoding, generic or non-point geometry path, OGC API Features routes, CQL2, EDR, tiles, reprojection, spatial joins, general search language, fuzzy or Record Match behavior, dynamic masking, caller-dependent maximum entitlement profile, PDP, consent workflow, response signing, credential lifecycle, multi-source analytics, runtime vocabulary fetch, RDF store, SPARQL, hot reload, write API, registry administration, formal GovStack compatibility mode, or compatibility work in `registryctl`. | ## Required acceptance coverage @@ -125,6 +125,17 @@ value-free operational log dimensions. ### Business registry cases - anonymous paginated list and identifier read over a captured snapshot; +- a separate public registered-premises resource with a classified, selectable + CRS84 Point assembled from reviewed longitude and latitude columns; +- exact inclusive bounded `bbox` search, boundary inclusion, malformed, + out-of-range, oversize, and antimeridian refusal, deterministic pagination, + and cursor rejection when bbox, governed representation, response format, or + GeoJSON profile changes; +- equivalent governed JSON, JSON-LD, RFC 7946 GeoJSON, and JSON-FG responses, + including a requested field subset that omits geometry; +- no GeoJSON negotiation on a nonspatial operation or a governed representation + that omits geometry, and no disclosure of an invalid coordinate row or its + values; - `pageSize`, first page, cursor page, and nullable `pageInfo.nextCursor` behavior; - no filter when allowed, each declared direct camelCase exact filter, a subset of declared filters, unknown filter, unsupported operator, and attempted arbitrary sort; - deterministic ordering and pagination with no duplicate or missing record across the unchanged snapshot; @@ -132,7 +143,8 @@ value-free operational log dimensions. - public default list/read can request only public representations; protected registrar representation metadata, schema, SHACL, JSON-LD, processing, and OpenAPI are absent from public discovery; - a public representation reads only the reviewed pre-derived public view, never a non-public raw column, and a profile-bound cursor or ETag cannot cross into another representation; - snapshot digest, path replacement, unsafe sidecar, write attempt, and schema mismatch failures. -- `consultation.list` and `consultation.retrieve` discovery with no unsupported family claim. +- `consultation.list`, `consultation.retrieve`, and the bounded point + `consultation.search` discovery with no unsupported family claim. ### Civil-event registry cases diff --git a/products/relay-v2/IMPLEMENTATION.md b/products/relay-v2/IMPLEMENTATION.md index de3eb1915..dc646d55d 100644 --- a/products/relay-v2/IMPLEMENTATION.md +++ b/products/relay-v2/IMPLEMENTATION.md @@ -112,6 +112,21 @@ permitted-representation artifacts. The latter requires Registry Core and validates selectable `domainData` properties when present; the former preserves source requiredness and full SHACL cardinality. +The spatial profile is deliberately closed. `primaryGeometry` has one name, +semantic term, classification, requiredness, exact CRS84 identifier, and two +reviewed SQLite source columns, `longitudeColumn` and `latitudeColumn`. It +serializes only a validated GeoJSON Point. An opted list's `spatialQuery.bbox` +sets maximum longitude and latitude spans in whole degrees. The existing +operation `representations` map continues to own access and disclosure. JSON +and JSON-LD are response formats for every selected governed representation; +GeoJSON is derived only when that representation discloses the primary +geometry, with `profile=rfc7946` or `profile=jsonfg` selecting its profile. +Because bbox is a collection-query selector, its primary geometry must have +effective `privacy: non-personal` classification. +There is no storage abstraction, dynamic SQLite extension, GeoPackage parser, +SpatiaLite dependency, generic geometry, or reprojection hidden behind these +fields. + `RelayRuntime` is a separate strict deployment file. It binds listener, `packagePath`, SQLite paths, at most one issuer and audience, secrets, cursor key, audit sink, timeouts, concurrency, quotas, and @@ -281,7 +296,8 @@ is deterministic for validators, but member order is not a client contract: ``` `family` is always `consultation`; `pattern` is `retrieve`, `list`, or -`search`. `sourceRevision.status` is `versioned` or `unversioned`; `value` is +`search`. A list constrained by declared point bbox is `search`. +`sourceRevision.status` is `versioned` or `unversioned`; `value` is null only for unversioned live data. `selectedFields` is always present in contract order, including when the caller omitted `fields`. No other member is nullable. `semanticModelReference` points to the local vocabulary/model while @@ -325,6 +341,15 @@ the bound SHACL shape, and maps transport-only `meta` and `pageInfo` to null so they do not become domain triples. Ordinary JSON retains the shapes above without `@context`, `@id`, or `@type`. +When the selected governed representation discloses the resource's primary +geometry, `application/geo+json` returns an RFC 7946 +Feature for a single Record and FeatureCollection for a list. Feature +`properties` carries Registry Core and selected non-spatial domain fields; +`geometry` is the selected Point or `null` when the requester omitted it. +Together they preserve the ordinary governed disclosure. The default +`profile=rfc7946` has no JSON-FG additions. `profile=jsonfg` adds only bounded +JSON-FG conformance and feature-type metadata from the same compiled operation. + ### HTTP binding and capabilities The initial routes are: @@ -388,8 +413,11 @@ Registry service identity remains public. Capability visibility derives from the operations included in resource metadata; it is not configured separately. List filters are direct declared camelCase query parameters, exact-equality -only, non-personal, unique, and cannot be named `pageSize`, `cursor`, or -`fields`. Any non-empty subset of declared filters is valid. The operation +only, non-personal, unique, and cannot be named `pageSize`, `cursor`, +`fields`, `bbox`, or `profile`. Any non-empty subset of declared filters is +valid. An opted point list may accept exactly one +`bbox=minLon,minLat,maxLon,maxLat` predicate. It is finite, CRS84-range-checked, +inclusive, non-wrapping, and bounded by compiled spans before source access. The operation explicitly declares whether the empty subset is allowed with `allowUnfiltered`. Transformed properties are response-only and fail compilation when named as a filter or fixed-order key. A queryable derived value must be a separately @@ -403,6 +431,12 @@ is reauthorized. Authenticated encryption prevents filters and keyset-order values from bypassing field minimization. A caller cannot sort, name a source column, add an operator, or traverse an uncompiled page. +Spatial context is issued in cursor version 2. During the bounded upgrade +window, Relay also accepts an integrity-valid version 1 cursor only as its +original nonspatial JSON query with no bbox or response-format profile. New +cursors are always version 2, and the ordinary expiry, contract, source, +operation, filter, field, order, and authorization bindings still apply. + The first page accepts `pageSize`, `fields`, and declared filters. A continuation request supplies exactly one `cursor` parameter and no `pageSize`, `fields`, or filters; the cursor restores the immutable query @@ -757,7 +791,8 @@ future compatibility profiles do not block focused implementation milestones. - Registry Manifest, DPV, safeguards, and machine-readable GovStack alignment projections; - Registry multi-tenancy, generic storage traits, PostgreSQL, SpatiaLite, - GeoJSON, relationships, nested properties, arrays, decimals, search language, + GeoPackage decoding, generic geometry, relationships, nested properties, + arrays, decimals, search language, fuzzy matching, and Record Match; - dynamic masking, general PDP, consent workflow, writes, notification, aggregate computation, principal-facing access history, response signing, diff --git a/products/relay-v2/README.md b/products/relay-v2/README.md index 697a1c4d1..d24ebd7f8 100644 --- a/products/relay-v2/README.md +++ b/products/relay-v2/README.md @@ -9,6 +9,8 @@ The initial boundary is intentionally narrow: - one Relay process serves one governed Registry; - SQLite is read-only and is the only source profile; +- an opted resource may expose one classified CRS84 Point and bounded exact + bbox search when that geometry is non-personal; - resources are Record types within the Registry; - compiled operations map only to Consultation Retrieve, List, and constrained Search; diff --git a/products/relay-v2/STANDARDS-ALIGNMENT.md b/products/relay-v2/STANDARDS-ALIGNMENT.md index 1d54233a4..58ef28b6b 100644 --- a/products/relay-v2/STANDARDS-ALIGNMENT.md +++ b/products/relay-v2/STANDARDS-ALIGNMENT.md @@ -17,6 +17,7 @@ claim. The obsolete Digital Registries OpenAPI is not an input. | Consultation Retrieve | Identifier read is compiled only when the resource declares `read`. | | Consultation List | Deterministic list is compiled only when the resource declares `list`; pagination and filters are closed. | | Consultation Search | A named exact lookup is the only accepted search-shaped operation. It returns one governed Record or the unresolved outcome. | +| Bounded spatial consultation | An opted point list with required bounded `bbox` is derived as `consultation.search`; it remains the same fixed consultation route and does not create an OGC API Features service. | | Registry semantics | Every resource and property has a stable local semantic identity; JSON-LD, JSON Schema, and SHACL artifacts are compiler outputs. | | Governed representations | A compiled operation may expose only its finite reviewed representations, each with its own access, disclosure, semantic, schema, SHACL, JSON-LD, classification, and processing artifact. This is controlled publication, not content negotiation or dynamic ABAC. | | Data governance review | Schema-only identification supplies deterministic review evidence. Classification remains Registry Authority review metadata and constrains compilation; it never grants an entitlement or becomes a remote runtime policy. | @@ -28,6 +29,12 @@ claim. The obsolete Digital Registries OpenAPI is not an input. - Exact lookup is not Record Match. Relay emits no candidates, confidence, or explanation. +- The point profile is not OGC API Features, CQL2, EDR, tiles, a coordinate + transformation service, or a generic spatial database API. It supports only + classified CRS84 Points from reviewed longitude and latitude columns and + exact inclusive, non-wrapping, bounded `bbox` containment. +- GeoPackage and SpatiaLite are future source-profile work. Relay neither + loads SQLite extensions nor accepts or emits GeoPackage geometry blobs. - Version 1 does not negotiate response language. Authored semantic labels retain their language metadata. - Registry Manifest, machine-readable GovStack alignment, DPV projection, and diff --git a/products/relay-v2/acceptance/business-registry/expected-http.yaml b/products/relay-v2/acceptance/business-registry/expected-http.yaml index 1eaf46a6b..7f5ea8a4b 100644 --- a/products/relay-v2/acceptance/business-registry/expected-http.yaml +++ b/products/relay-v2/acceptance/business-registry/expected-http.yaml @@ -14,8 +14,8 @@ steps: request: {method: GET, path: /v2} expect: status: 200 - capabilityPatterns: [consultation.list, consultation.retrieve] - absentCapabilityPatterns: [consultation.search, evidence, write, notification] + capabilityPatterns: [consultation.list, consultation.retrieve, consultation.search] + absentCapabilityPatterns: [evidence, write, notification] - id: first-page request: method: GET @@ -113,6 +113,175 @@ steps: status: 304 bodyEmpty: true etagSameAs: identifier-read + - id: premises-first-page + request: + method: GET + path: /v2/resources/registered-premises/records + query: {bbox: "100,13,101,14", pageSize: 2} + expect: + status: 200 + itemCount: 2 + nextCursor: non-null + registryCoreRequired: true + domainDataKeys: [premisesIdentifier, businessRegistrationNumber, premisesName, location] + - id: premises-second-page + request: + method: GET + path: /v2/resources/registered-premises/records + query: {cursor: "$nextCursor:premises-first-page"} + expect: + status: 200 + itemCount: 1 + nextCursor: "null" + registryCoreRequired: true + - id: premises-boundary-point + request: + method: GET + path: /v2/resources/registered-premises/records + query: {bbox: "101,14,101,14"} + expect: {status: 200, itemCount: 1, registryCoreRequired: true} + - id: premises-fields-omit-location + request: + method: GET + path: /v2/resources/registered-premises/records + query: {bbox: "100,13,101,14", pageSize: 4, fields: "premisesIdentifier,premisesName"} + expect: + status: 200 + itemCount: 3 + registryCoreRequired: true + domainDataKeys: [premisesIdentifier, premisesName] + - id: premises-read + request: {method: GET, path: /v2/resources/registered-premises/records/PREM-SYNTH-0001} + expect: + status: 200 + recordIdentifier: PREM-SYNTH-0001 + registryCoreRequired: true + domainDataKeys: [premisesIdentifier, businessRegistrationNumber, premisesName, location] + - id: premises-read-jsonld + request: + method: GET + path: /v2/resources/registered-premises/records/PREM-SYNTH-0001 + headers: {accept: application/ld+json} + expect: + status: 200 + registryCoreRequired: true + domainDataKeys: [premisesIdentifier, businessRegistrationNumber, premisesName, location] + recordsEquivalentTo: premises-read + - id: premises-feature-collection + request: + method: GET + path: /v2/resources/registered-premises/records + headers: {accept: application/geo+json} + query: {bbox: "100,13,101,14", profile: rfc7946} + expect: + status: 200 + itemCount: 2 + nextCursor: non-null + registryCoreRequired: true + domainDataKeys: [premisesIdentifier, businessRegistrationNumber, premisesName] + geoJsonRoot: feature-collection + geometryType: Point + representationProfile: rfc7946 + recordsEquivalentTo: premises-first-page + - id: premises-feature-collection-jsonfg + request: + method: GET + path: /v2/resources/registered-premises/records + headers: {accept: application/geo+json} + query: {bbox: "100,13,101,14", profile: jsonfg} + expect: + status: 200 + itemCount: 2 + nextCursor: non-null + registryCoreRequired: true + domainDataKeys: [premisesIdentifier, businessRegistrationNumber, premisesName] + geoJsonRoot: feature-collection + geometryType: Point + representationProfile: jsonfg + recordsEquivalentTo: premises-first-page + - id: premises-feature-read + request: + method: GET + path: /v2/resources/registered-premises/records/PREM-SYNTH-0001 + headers: {accept: application/geo+json} + query: {profile: rfc7946} + expect: + status: 200 + recordIdentifier: PREM-SYNTH-0001 + registryCoreRequired: true + domainDataKeys: [premisesIdentifier, businessRegistrationNumber, premisesName] + geoJsonRoot: feature + geometryType: Point + representationProfile: rfc7946 + recordsEquivalentTo: premises-read + - id: premises-feature-fields-omit-location + request: + method: GET + path: /v2/resources/registered-premises/records + headers: {accept: application/geo+json} + query: {bbox: "100,13,101,14", pageSize: 4, fields: "premisesIdentifier,premisesName", profile: rfc7946} + expect: + status: 200 + itemCount: 3 + nextCursor: "null" + registryCoreRequired: true + domainDataKeys: [premisesIdentifier, premisesName] + geoJsonRoot: feature-collection + geometryType: "null" + representationProfile: rfc7946 + recordsEquivalentTo: premises-fields-omit-location + - id: premises-invalid-bbox + request: + method: GET + path: /v2/resources/registered-premises/records + query: {bbox: "not,a,bbox"} + expect: {status: 400, code: filter.invalid_value} + - id: premises-out-of-range-bbox + request: + method: GET + path: /v2/resources/registered-premises/records + query: {bbox: "-181,13,101,14"} + expect: + status: 400 + code: filter.invalid_value + absentEverywhere: ["-181"] + - id: premises-oversize-bbox + request: + method: GET + path: /v2/resources/registered-premises/records + query: {bbox: "100,13,103,14"} + expect: {status: 400, code: filter.invalid_value} + - id: premises-antimeridian-bbox + request: + method: GET + path: /v2/resources/registered-premises/records + query: {bbox: "101,13,100,14"} + expect: {status: 400, code: filter.invalid_value} + - id: premises-cursor-bbox-binding + request: + method: GET + path: /v2/resources/registered-premises/records + query: {cursor: "$nextCursor:premises-first-page", bbox: "100,13,101,14"} + expect: {status: 400, code: query.cursor_invalid} + - id: premises-cursor-profile-binding + request: + method: GET + path: /v2/resources/registered-premises/records + headers: {accept: application/geo+json} + query: {cursor: "$nextCursor:premises-first-page"} + expect: {status: 400, code: query.cursor_invalid} + - id: nonspatial-geojson-refused + request: + method: GET + path: /v2/resources/registered-business/records/BIZ-SYNTH-0001 + headers: {accept: application/geo+json} + expect: {status: 406, code: representation.unsupported} + - id: invalid-coordinate-row + request: {method: GET, path: /v2/resources/registered-premises/records/PREM-SYNTH-BAD1} + expect: + status: 503 + code: source.unavailable + absentEverywhere: ["95.0", "Unsafe coordinate fixture"] - id: unknown-field request: method: GET diff --git a/products/relay-v2/acceptance/business-registry/fixture.sql b/products/relay-v2/acceptance/business-registry/fixture.sql index b17a31d0e..b37c31da2 100644 --- a/products/relay-v2/acceptance/business-registry/fixture.sql +++ b/products/relay-v2/acceptance/business-registry/fixture.sql @@ -32,3 +32,32 @@ SELECT registration_number, legal_form, jurisdiction_code FROM source_registered_businesses; + +CREATE TABLE source_registered_premises ( + premises_identifier TEXT PRIMARY KEY NOT NULL, + record_revision TEXT NOT NULL, + lifecycle_state TEXT NOT NULL, + recorded_at TEXT NOT NULL, + business_registration_number TEXT NOT NULL, + premises_name TEXT NOT NULL, + longitude REAL NOT NULL, + latitude REAL NOT NULL +) STRICT; + +INSERT INTO source_registered_premises VALUES +('PREM-SYNTH-0001', '3', 'ACTIVE', '2026-06-01T08:00:00Z', 'BIZ-SYNTH-0001', 'Orchard cooperative market', 100.0, 13.0), +('PREM-SYNTH-0002', '2', 'ACTIVE', '2026-06-02T08:00:00Z', 'BIZ-SYNTH-0002', 'River trading warehouse', 100.5, 13.5), +('PREM-SYNTH-0003', '5', 'ACTIVE', '2026-06-03T08:00:00Z', 'BIZ-SYNTH-0003', 'Workshop meeting hall', 101.0, 14.0), +('PREM-SYNTH-0004', '1', 'RETIRED', '2026-06-04T08:00:00Z', 'BIZ-SYNTH-0004', 'Fixture market store', 102.0, 15.0), +('PREM-SYNTH-BAD1', '1', 'ACTIVE', '2026-06-05T08:00:00Z', 'BIZ-SYNTH-0002', 'Unsafe coordinate fixture', 100.25, 95.0); + +CREATE VIEW relay_registered_premises AS +SELECT premises_identifier, + record_revision, + lifecycle_state, + recorded_at, + business_registration_number, + premises_name, + longitude, + latitude +FROM source_registered_premises; diff --git a/products/relay-v2/acceptance/business-registry/governance/classification-review.yaml b/products/relay-v2/acceptance/business-registry/governance/classification-review.yaml index 26758edd8..27d1ca608 100644 --- a/products/relay-v2/acceptance/business-registry/governance/classification-review.yaml +++ b/products/relay-v2/acceptance/business-registry/governance/classification-review.yaml @@ -1,7 +1,7 @@ apiVersion: relay.registrystack.org/classification-review/v1 kind: ClassificationReview registryIdentifier: urn:example:registry:registered-businesses -classificationInventoryDigest: sha256:730dc3a3fed72d17efb443fc482533047ad716129126c78a78dcdd3500010c6d +classificationInventoryDigest: sha256:29522c4490bb26724b21e26bdd8fc8ebea9df0df12d75d23dc0b148eb62c0c40 method: imported reviewer: urn:example:institution:company-registrar reviewDate: 2026-08-10 diff --git a/products/relay-v2/acceptance/business-registry/registry.yaml b/products/relay-v2/acceptance/business-registry/registry.yaml index 4f0654e45..8690d349d 100644 --- a/products/relay-v2/acceptance/business-registry/registry.yaml +++ b/products/relay-v2/acceptance/business-registry/registry.yaml @@ -32,7 +32,7 @@ semantics: alignments: - id: semic-business-alignment profileRef: semantics/semic-business-alignment.yaml - digest: sha256:69c6efc233798949fb69457583733b837ce7fa36d238828e9357533383f197c3 + digest: sha256:6a46a9be0a3d5b4a5650934c7e8ef73ad1803cb479981f7d235a1c17a335af52 version: "1" relationRequired: true classifications: @@ -44,7 +44,7 @@ sources: companies: kind: sqlite profile: snapshot - expectedSchemaFingerprint: sha256:5f12cd971dfa9cafd98018bd8723f2c3902e7d96b3c57ecf5c20e4885eb806a8 + expectedSchemaFingerprint: sha256:dd62b98578f0fa7341eeeaaac4b34da9b79405ae067dc06e5edb004c2d4a38fe resources: - id: registered-business title: Registered business @@ -153,6 +153,75 @@ resources: legalBasisRef: governance/legal-basis.yaml dpvProfileRef: governance/legal-basis.yaml safeguards: [reviewed-public-view, property-minimization, deterministic-pagination, change-impact-review] + - id: registered-premises + title: Registered premises + description: Current synthetic public premises locations associated with registered businesses. + semanticClass: local:RegisteredPremises + source: + source: companies + view: relay_registered_premises + classificationDefaults: {privacy: non-personal, institutional: public, handling: public, status: reviewed} + recordContext: + recordIdentifier: {sourceColumn: premises_identifier} + revisionIdentifier: {sourceColumn: record_revision} + lifecycleState: {sourceColumn: lifecycle_state, codelist: codelists/record-lifecycle.yaml} + recordedAt: {sourceColumn: recorded_at} + primaryGeometry: + name: location + label: Premises location + description: Reviewed point location of the registered premises in CRS84 longitude-latitude order. + semanticTerm: local:location + sourceRequired: true + crs: http://www.opengis.net/def/crs/OGC/0/CRS84 + source: {longitudeColumn: longitude, latitudeColumn: latitude} + classification: {privacy: non-personal, institutional: public, handling: public, status: reviewed} + properties: + premisesIdentifier: + sourceColumn: premises_identifier + type: string + sourceRequired: true + semanticTerm: local:premisesIdentifier + label: Premises identifier + description: Stable synthetic identifier for the registered premises Record. + businessRegistrationNumber: + sourceColumn: business_registration_number + type: string + sourceRequired: true + semanticTerm: local:businessRegistrationNumber + label: Business registration number + description: Registration number of the business associated with the premises. + premisesName: + sourceColumn: premises_name + type: string + sourceRequired: true + semanticTerm: local:premisesName + label: Premises name + description: Published name of the synthetic registered premises. + disclosureProfiles: + public-premises: + properties: [premisesIdentifier, businessRegistrationNumber, premisesName, location] + operations: + list: + defaultRepresentation: public-premises + representations: + public-premises: {access: public, disclosureProfile: public-premises} + allowUnfiltered: false + spatialQuery: + bbox: {maximumLongitudeSpanDegrees: 2, maximumLatitudeSpanDegrees: 2} + orderBy: [premisesIdentifier] + pagination: {defaultPageSize: 2, maximumPageSize: 4} + read: + defaultRepresentation: public-premises + representations: + public-premises: {access: public, disclosureProfile: public-premises} + processingDescriptions: + - id: public-premises-publication + operationRefs: [list, read] + purpose: statutory-publication + recipientClass: public + legalBasisRef: governance/legal-basis.yaml + dpvProfileRef: governance/legal-basis.yaml + safeguards: [reviewed-public-view, geometry-disclosure-review, bounded-point-bbox, deterministic-pagination, change-impact-review] metadataVisibility: service: public resources: public diff --git a/products/relay-v2/acceptance/business-registry/semantics/local-vocabulary.yaml b/products/relay-v2/acceptance/business-registry/semantics/local-vocabulary.yaml index 583c1000e..631d86134 100644 --- a/products/relay-v2/acceptance/business-registry/semantics/local-vocabulary.yaml +++ b/products/relay-v2/acceptance/business-registry/semantics/local-vocabulary.yaml @@ -4,9 +4,14 @@ origin: curated-local reviewStatus: reviewed classes: - {id: RegisteredBusiness, label: Registered business, description: Synthetic current business registration.} + - {id: RegisteredPremises, label: Registered premises, description: Synthetic current premises location associated with a registered business.} properties: - {id: registrationNumber, label: Registration number, datatype: string, cardinality: one} - {id: legalName, label: Legal name, datatype: string, cardinality: one} - {id: registrationStatus, label: Registration status, datatype: controlled-code, cardinality: one} - {id: legalForm, label: Legal form, datatype: controlled-code, cardinality: one} - {id: registeredJurisdiction, label: Registered jurisdiction, datatype: controlled-code, cardinality: one} + - {id: premisesIdentifier, label: Premises identifier, datatype: string, cardinality: one} + - {id: businessRegistrationNumber, label: Business registration number, datatype: string, cardinality: one} + - {id: premisesName, label: Premises name, datatype: string, cardinality: one} + - {id: location, label: Premises location, datatype: geojson-point, cardinality: one} diff --git a/products/relay-v2/acceptance/business-registry/semantics/semic-business-alignment.yaml b/products/relay-v2/acceptance/business-registry/semantics/semic-business-alignment.yaml index f1501153c..cab168da5 100644 --- a/products/relay-v2/acceptance/business-registry/semantics/semic-business-alignment.yaml +++ b/products/relay-v2/acceptance/business-registry/semantics/semic-business-alignment.yaml @@ -6,3 +6,5 @@ status: illustrative-pinned-input mappings: - {local: local:RegisteredBusiness, external: https://data.europa.eu/m8g/LegalEntity, relation: close} - {local: local:legalName, external: https://data.europa.eu/m8g/legalName, relation: close} + - {local: local:RegisteredPremises, external: https://schema.org/Place, relation: related} + - {local: local:location, external: https://schema.org/geo, relation: related} diff --git a/products/relay-v2/contracts/acceptance-scenario-matrix.yaml b/products/relay-v2/contracts/acceptance-scenario-matrix.yaml index 8f8bdd374..44fa69c55 100644 --- a/products/relay-v2/contracts/acceptance-scenario-matrix.yaml +++ b/products/relay-v2/contracts/acceptance-scenario-matrix.yaml @@ -49,6 +49,24 @@ scenarios: - {id: business-sort-rejection, project: business-registry, journeyStep: arbitrary-sort, assertion: Callers cannot choose source ordering.} - {id: business-operator-rejection, project: business-registry, journeyStep: unsupported-filter-operator, assertion: Callers cannot add an operator to a declared direct filter.} - {id: business-invalid, project: business-registry, journeyStep: invalid-source-row, invalidSourceRowClass: wrong-type, expectedStatus: 503, expectedCode: source.unavailable, assertion: An invalid source row fails closed and is not released.} + - {id: business-premises-first-page, project: business-registry, journeyStep: premises-first-page, assertion: A bounded public Point search is paginated and exposes only its governed geometry and properties.} + - {id: business-premises-second-page, project: business-registry, journeyStep: premises-second-page, assertion: A Point-search cursor retrieves the remaining exact-bbox result page.} + - {id: business-premises-boundary, project: business-registry, journeyStep: premises-boundary-point, assertion: Exact Point bbox containment includes a point on every bounding edge.} + - {id: business-premises-minimization, project: business-registry, journeyStep: premises-fields-omit-location, assertion: A requester can omit the classified geometry without changing the governed representation.} + - {id: business-premises-read, project: business-registry, journeyStep: premises-read, assertion: Identifier read returns the same governed Point field in the ordinary Registry Record.} + - {id: business-premises-jsonld, project: business-registry, journeyStep: premises-read-jsonld, assertion: JSON-LD preserves the same governed Point data and Registry Core while applying the generated context.} + - {id: business-premises-geojson, project: business-registry, journeyStep: premises-feature-collection, assertion: The selected governed representation serializes as RFC 7946 GeoJSON.} + - {id: business-premises-jsonfg, project: business-registry, journeyStep: premises-feature-collection-jsonfg, assertion: JSON-FG is an explicit response profile over the same governed Point data.} + - {id: business-premises-feature-read, project: business-registry, journeyStep: premises-feature-read, assertion: Identifier read serializes one governed Record as a GeoJSON Feature.} + - {id: business-premises-feature-minimization, project: business-registry, journeyStep: premises-feature-fields-omit-location, assertion: GeoJSON field minimization emits an explicit null geometry and does not reintroduce the omitted Point through Feature properties.} + - {id: business-premises-invalid-bbox, project: business-registry, journeyStep: premises-invalid-bbox, assertion: Malformed Point bbox input fails before source access.} + - {id: business-premises-out-of-range-bbox, project: business-registry, journeyStep: premises-out-of-range-bbox, assertion: A CRS84 coordinate outside the world range is refused without echoing its value.} + - {id: business-premises-oversize-bbox, project: business-registry, journeyStep: premises-oversize-bbox, assertion: A bbox outside the publisher span ceiling is refused.} + - {id: business-premises-antimeridian, project: business-registry, journeyStep: premises-antimeridian-bbox, assertion: The initial Point profile refuses antimeridian-crossing bbox input.} + - {id: business-premises-cursor-bbox, project: business-registry, journeyStep: premises-cursor-bbox-binding, assertion: A cursor cannot be combined with a first-page bbox parameter.} + - {id: business-premises-cursor-profile, project: business-registry, journeyStep: premises-cursor-profile-binding, assertion: A cursor cannot cross its negotiated response format or GeoJSON profile.} + - {id: business-nonspatial-geojson, project: business-registry, journeyStep: nonspatial-geojson-refused, assertion: GeoJSON is unavailable when the selected governed representation does not disclose a primary geometry.} + - {id: business-premises-invalid-coordinate, project: business-registry, journeyStep: invalid-coordinate-row, invalidSourceRowClass: wrong-type, expectedStatus: 503, expectedCode: source.unavailable, assertion: An unsafe coordinate row fails closed without a value-bearing diagnostic.} - {id: civil-discovery, project: civil-event, journeyStep: registry-discovery, assertion: Discovery derives retrieve and constrained search only.} - {id: civil-read, project: civil-event, journeyStep: registrar-read, assertion: The registrar scope receives its narrower requested representation.} - {id: civil-read-default, project: civil-event, journeyStep: registrar-read-default, assertion: The default representation contains exactly the compiled disclosure profile.} diff --git a/products/relay-v2/contracts/artifact-inventory.yaml b/products/relay-v2/contracts/artifact-inventory.yaml index 851802354..64f589a46 100644 --- a/products/relay-v2/contracts/artifact-inventory.yaml +++ b/products/relay-v2/contracts/artifact-inventory.yaml @@ -17,6 +17,12 @@ artifacts: source: compiled-resource generated: true invariant: One artifact exists per compiled operation representation and validates mandatory Registry Core with every allowed selected-profile domainData subset. + - id: geojson-response-schema + mediaType: application/schema+json + visibility: operation-compatible + source: compiled-operation-representation + generated: true + invariant: Exists only when a selected governed representation discloses the resource primary Point geometry and validates its RFC 7946 or JSON-FG response shape. - id: representation-shacl mediaType: text/turtle visibility: operation-compatible diff --git a/products/relay-v2/contracts/generated-baselines.yaml b/products/relay-v2/contracts/generated-baselines.yaml index 6e95a90da..a55a2797c 100644 --- a/products/relay-v2/contracts/generated-baselines.yaml +++ b/products/relay-v2/contracts/generated-baselines.yaml @@ -2,7 +2,7 @@ schemaVersion: relay.registrystack.org/generated-baselines/v1alpha1 product: relay-v2 projects: social-assistance: - packageRevision: sha256:bb39e792cbe09e374e5e8fd3ebb468af64b99838158a273a6f48163b0179ae99 + packageRevision: sha256:8e2f70e492bbfbbf626b18d9998c03b0588155bc3419c61fdecabc88a263ae46 contractRevision: sha256:0fd06f53b937afbb0252715010ff222c4cb8817a6c62648a2a72ac4d35eae282 sourceSchemaFingerprints: assistance: sha256:936a90a03d06be67a76226d6999a830c04f6604a3ff8b340a62fdd378d8c6d91 @@ -12,7 +12,7 @@ projects: operationIdentifier: assistance-enrolment.lookup.by-case-and-person path: generated/artifacts/assistance-enrolment--lookup-by-case-and-person--representation-caseworker.capability.json representationIdentifier: caseworker - sha256: sha256:1d67b9dace04ce6f30dc845de42fc6f56574f5ff28eda76c55defe46bc1fabcd + sha256: sha256:a9993929a908172d36ac50e787f3c4a87a39ba64cb8e677c19d9ff000083dca0 visibility: operation-bound - id: assistance-enrolment--lookup-by-case-and-person--representation-caseworker-classifications mediaType: application/json @@ -61,7 +61,7 @@ projects: operationIdentifier: assistance-enrolment.lookup.by-case-and-person path: generated/artifacts/assistance-enrolment--lookup-by-case-and-person--representation-limited.capability.json representationIdentifier: limited - sha256: sha256:4f8f10720a121ab9d7355bf214ac03d19c3024312a07e7a079935acaa1c76565 + sha256: sha256:7aa208acd66c410516ffaade76c2be7b71e932bc774e39917e3aadc8d142a2d6 visibility: operation-bound - id: assistance-enrolment--lookup-by-case-and-person--representation-limited-classifications mediaType: application/json @@ -173,7 +173,7 @@ projects: operationIdentifier: null path: generated/artifacts/capabilities.full.json representationIdentifier: null - sha256: sha256:fba819496d80b2583a996f16f8629c01452011bec2cb9819b6b0327214380b29 + sha256: sha256:0f87ce2269c666b3ecbbc48600d3f02c83c41e110eee58c86ce866326b5b9e05 visibility: operator-only - id: capability-inventory mediaType: application/json @@ -187,7 +187,7 @@ projects: operationIdentifier: null path: generated/openapi.full.yaml representationIdentifier: null - sha256: sha256:b7ea4f0e0c1d392b05e8862dbec696d5ced4439d184dc9c9c9b83af3ee842d5b + sha256: sha256:ea709aa981e71094cfa8be399932d35935bac0a2c8fa04548ef2e1757045d777 visibility: operator-only - id: openapi-public mediaType: application/json @@ -252,10 +252,10 @@ projects: size: 6472 visibility: operator-only business-registry: - packageRevision: sha256:6bc260ff78b6600b4c6b019e00a79a41e604cb92adcb64142eb0a15c464ca52d - contractRevision: sha256:6c4ea84a578cf589ffb851722830f90d7c241998d82ea419f7b823640cd056b5 + packageRevision: sha256:d66bbcae2a74ef5120c1f7f9f0577bc49be6b4da25ff209aa33463c0911b4da1 + contractRevision: sha256:6edba24daf8fd0550a3b2e6ee163e327dd1cbbc3132d51efb88eb52ca9e45461 sourceSchemaFingerprints: - companies: sha256:5f12cd971dfa9cafd98018bd8723f2c3902e7d96b3c57ecf5c20e4885eb806a8 + companies: sha256:dd62b98578f0fa7341eeeaaac4b34da9b79405ae067dc06e5edb004c2d4a38fe artifacts: - id: audit-event-schema mediaType: application/schema+json @@ -269,14 +269,14 @@ projects: operationIdentifier: null path: generated/artifacts/capabilities.full.json representationIdentifier: null - sha256: sha256:fd531b5dc2fa73f708d17dde5edc02b35c56968247eab1e92dc5134efe65c950 + sha256: sha256:cec26d48f7eced706ac187413f760ef77900bc6b0b527bd9a2d1a885203c1052 visibility: operator-only - id: capability-inventory mediaType: application/json operationIdentifier: null path: generated/artifacts/capabilities.json representationIdentifier: null - sha256: sha256:34810616f877e3226d0b3d94fad2c1e412c54d94243e4665a09e98fbcd298785 + sha256: sha256:6adb3834c7eab77ee86bda24a2348bfb8ddf867ce23087fc1036df0ddc3f0688 visibility: public - id: registered-business--list--representation-public-register-classifications mediaType: application/json @@ -325,7 +325,7 @@ projects: operationIdentifier: registered-business.list path: generated/artifacts/registered-business--list--representation-registrar.capability.json representationIdentifier: registrar - sha256: sha256:f102dc91767c0bff76e61dec2f8b948c230025b4b1eb7f2d6af343c2bda7c0a1 + sha256: sha256:08fa0e9dbdf23b056afb56a998b5e5da84085905d365658d9f27e84e5cd73603 visibility: operation-bound - id: registered-business--list--representation-registrar-classifications mediaType: application/json @@ -416,7 +416,7 @@ projects: operationIdentifier: registered-business.read path: generated/artifacts/registered-business--read--representation-registrar.capability.json representationIdentifier: registrar - sha256: sha256:a96f07fbf9b61fed41e01b49d4e6bb5144e568bbbfec53cf9fd8f430c857d643 + sha256: sha256:4b0874aa8385cc40a92d8a85c5678aaf71b5a3bd1beca76dc42bdb11484e8487 visibility: operation-bound - id: registered-business--read--representation-registrar-classifications mediaType: application/json @@ -523,19 +523,159 @@ projects: representationIdentifier: null sha256: sha256:9e14c3d53958f18e29ee021c74f6f8ea0ceacb0452d01f5f13f5ea7270006158 visibility: operator-only + - id: registered-premises--list--representation-public-premises-classifications + mediaType: application/json + operationIdentifier: null + path: generated/artifacts/registered-premises--list--representation-public-premises.classifications.json + representationIdentifier: null + sha256: sha256:79050bbc149ff904fc4a8623409ccce3ef03e707d72d8f3ae21836281343d281 + visibility: public + - id: registered-premises--list--representation-public-premises-context + mediaType: application/ld+json + operationIdentifier: null + path: generated/artifacts/registered-premises--list--representation-public-premises.context.jsonld + representationIdentifier: null + sha256: sha256:93d1989d92502293a18f4e9845094fedf8ff96a5ebb91fddaab5728cb1cd9161 + visibility: public + - id: registered-premises--list--representation-public-premises-geojson-schema + mediaType: application/schema+json + operationIdentifier: null + path: generated/artifacts/registered-premises--list--representation-public-premises.geojson.schema.json + representationIdentifier: null + sha256: sha256:2c37c844b603a6268e5605a6d8851d775ec1bd3fea15c2a65be8544489b93a4c + visibility: public + - id: registered-premises--list--representation-public-premises-processing + mediaType: application/json + operationIdentifier: null + path: generated/artifacts/registered-premises--list--representation-public-premises.processing.json + representationIdentifier: null + sha256: sha256:14685ccde064a982f643b8165477abf0ae37b485f7bb6d5fdbc10a77cf6d9577 + visibility: public + - id: registered-premises--list--representation-public-premises-schema + mediaType: application/schema+json + operationIdentifier: null + path: generated/artifacts/registered-premises--list--representation-public-premises.schema.json + representationIdentifier: null + sha256: sha256:2482dcc6fdf6099706e812323a3e5b6244460c298e528783308d1022ae813b05 + visibility: public + - id: registered-premises--list--representation-public-premises-shacl + mediaType: text/turtle + operationIdentifier: null + path: generated/artifacts/registered-premises--list--representation-public-premises.shacl.ttl + representationIdentifier: null + sha256: sha256:56b593e9b20700ee37257de5ea749366deadf6e1d760cc3da5e90ce28f955e8c + visibility: public + - id: registered-premises--list--representation-public-premises-vocabulary + mediaType: application/ld+json + operationIdentifier: null + path: generated/artifacts/registered-premises--list--representation-public-premises.vocabulary.jsonld + representationIdentifier: null + sha256: sha256:3af77a0e9a5c087638b560ff6da1c886d0e6ab11f5961e6882d4cb69b60fb994 + visibility: public + - id: registered-premises--read--representation-public-premises-classifications + mediaType: application/json + operationIdentifier: null + path: generated/artifacts/registered-premises--read--representation-public-premises.classifications.json + representationIdentifier: null + sha256: sha256:9ba4c3330ed6c9bfc86ff0622636b26fb071353fd7c9378d911db58ec59d52cd + visibility: public + - id: registered-premises--read--representation-public-premises-context + mediaType: application/ld+json + operationIdentifier: null + path: generated/artifacts/registered-premises--read--representation-public-premises.context.jsonld + representationIdentifier: null + sha256: sha256:93d1989d92502293a18f4e9845094fedf8ff96a5ebb91fddaab5728cb1cd9161 + visibility: public + - id: registered-premises--read--representation-public-premises-geojson-schema + mediaType: application/schema+json + operationIdentifier: null + path: generated/artifacts/registered-premises--read--representation-public-premises.geojson.schema.json + representationIdentifier: null + sha256: sha256:8365aedc8a50a39831d53c12e2180d20ac2e3e34f0c3e62c0d9e6afe4b3f3a46 + visibility: public + - id: registered-premises--read--representation-public-premises-processing + mediaType: application/json + operationIdentifier: null + path: generated/artifacts/registered-premises--read--representation-public-premises.processing.json + representationIdentifier: null + sha256: sha256:bd960527a4b1ecfa83d89eaa9a77d161fd22e15513cd18784765e35230ae22bd + visibility: public + - id: registered-premises--read--representation-public-premises-schema + mediaType: application/schema+json + operationIdentifier: null + path: generated/artifacts/registered-premises--read--representation-public-premises.schema.json + representationIdentifier: null + sha256: sha256:8604d66989f9ed7830a922678b73bd6cf1334283e2f9c3e0d482a2553ca9c70b + visibility: public + - id: registered-premises--read--representation-public-premises-shacl + mediaType: text/turtle + operationIdentifier: null + path: generated/artifacts/registered-premises--read--representation-public-premises.shacl.ttl + representationIdentifier: null + sha256: sha256:56b593e9b20700ee37257de5ea749366deadf6e1d760cc3da5e90ce28f955e8c + visibility: public + - id: registered-premises--read--representation-public-premises-vocabulary + mediaType: application/ld+json + operationIdentifier: null + path: generated/artifacts/registered-premises--read--representation-public-premises.vocabulary.jsonld + representationIdentifier: null + sha256: sha256:3af77a0e9a5c087638b560ff6da1c886d0e6ab11f5961e6882d4cb69b60fb994 + visibility: public + - id: registered-premises-classification + mediaType: application/json + operationIdentifier: null + path: generated/artifacts/registered-premises.classifications.json + representationIdentifier: null + sha256: sha256:2e3d1a8bbcbf15b20575d050ef4a9dad65284e777856b83369f2948c5b2a3f7f + visibility: operator-only + - id: registered-premises-codelist-0 + mediaType: application/schema+json + operationIdentifier: null + path: generated/artifacts/registered-premises.codelist-0.schema.json + representationIdentifier: null + sha256: sha256:e42dfbcab45a66032d126e0f203523ae44a6bc034278f2ce222f96f1ff0a78f0 + visibility: operator-only + - id: registered-premises-full-schema + mediaType: application/schema+json + operationIdentifier: null + path: generated/artifacts/registered-premises.full.schema.json + representationIdentifier: null + sha256: sha256:7235a46db233c30dfdb4bb22834a111e106bd7768db273976639775664728b18 + visibility: operator-only + - id: registered-premises-full-shacl + mediaType: text/turtle + operationIdentifier: null + path: generated/artifacts/registered-premises.full.shacl.ttl + representationIdentifier: null + sha256: sha256:b138dab8da2dcda7fb717bbcab3ca20dd45dfa7dd24e39eb13af05b2472bd5fe + visibility: operator-only + - id: registered-premises-full-vocabulary + mediaType: application/ld+json + operationIdentifier: null + path: generated/artifacts/registered-premises.full.vocabulary.jsonld + representationIdentifier: null + sha256: sha256:3af77a0e9a5c087638b560ff6da1c886d0e6ab11f5961e6882d4cb69b60fb994 + visibility: operator-only + - id: registered-premises-processing-full + mediaType: application/json + operationIdentifier: null + path: generated/artifacts/registered-premises.processing.full.json + representationIdentifier: null + sha256: sha256:e231deecb5fce85a8bc492a23df1a3c5bdcdf37a4d19329ce994024efcdb35a9 + visibility: operator-only - id: openapi-full mediaType: application/yaml operationIdentifier: null path: generated/openapi.full.yaml representationIdentifier: null - sha256: sha256:f0f97d965af38fde18f51bdd9aff22199541ff64f51c9c10635fb43c137b4896 + sha256: sha256:8766ffc1ade253b49dd8f28b8f8cb015fe12689fa23aa2cd9860db64bdad3f8c visibility: operator-only - id: openapi-public mediaType: application/json operationIdentifier: null path: generated/openapi.public.json representationIdentifier: null - sha256: sha256:81707ee9bbf12e1ed50f126be4ccb07303decb986d66c2f8ff481c1eeec5a59e + sha256: sha256:09636233393eed2ae33fb1755db8bb617064c72dafbb2e5d83a93fe782825c6a visibility: public governedFiles: - generated: false @@ -571,7 +711,7 @@ projects: - generated: false mediaType: application/yaml path: governed/governance/classification-review.yaml - sha256: sha256:01d76e58b426f841c1ea75da053253e2e13dc434957b94ce79323f8f0d9eb3dc + sha256: sha256:4ab06258e697172dc87ca22940316d75933c65f9645ef4e35d78fab2c82aab3c size: 423 visibility: operator-only - generated: false @@ -589,17 +729,17 @@ projects: - generated: false mediaType: application/yaml path: governed/semantics/semic-business-alignment.yaml - sha256: sha256:69c6efc233798949fb69457583733b837ce7fa36d238828e9357533383f197c3 - size: 494 + sha256: sha256:6a46a9be0a3d5b4a5650934c7e8ef73ad1803cb479981f7d235a1c17a335af52 + size: 668 visibility: operator-only - generated: false mediaType: application/yaml path: registry.yaml - sha256: sha256:6e5d71e5b25bb74e75a9027b044a5292eca0c360bda576952cdd10c3a34d1550 - size: 7020 + sha256: sha256:6cd9dd4b7d58a55d944092dc22eee69a60e02339d52351a69fd6fc85556d6b23 + size: 10159 visibility: operator-only civil-event: - packageRevision: sha256:1321aa211982705a63644c45435dc716aacf36b9f0a5872013202fb1985446c1 + packageRevision: sha256:c90611d7542534408fceb4f921a144004241e85e6fb745a1a07878cf4d8faaa4 contractRevision: sha256:44a3604af965eb51983e44c8cebbb2486cd0655bfe265ce8a47cef22c8f1246a sourceSchemaFingerprints: events: sha256:7f770d64cb19ec54caca2aa56378b13a43cd5edc206ff44b5fecc99ee9e63759 @@ -616,7 +756,7 @@ projects: operationIdentifier: null path: generated/artifacts/capabilities.full.json representationIdentifier: null - sha256: sha256:7ac021239eaf96b23637b3c0fb2606592272ecb46c79d72b2b4fdbf4213a0660 + sha256: sha256:a830d6a256ee465e0f790671adb6e73190dbaeae6a180a3efe7995246080a9be visibility: operator-only - id: capability-inventory mediaType: application/json @@ -630,7 +770,7 @@ projects: operationIdentifier: civil-event.lookup.verify-registration path: generated/artifacts/civil-event--lookup-verify-registration--representation-registrar-verification.capability.json representationIdentifier: registrar-verification - sha256: sha256:f90a9e40f3c5c74fb6ac659a9d89526d2e9dfd1d2c43edfc28dba5b6d91014f7 + sha256: sha256:05b573b81da9abfc5043aa542f7dfe756a0da1192ab9b0640b31b809fcfa8b1b visibility: operation-bound - id: civil-event--lookup-verify-registration--representation-registrar-verification-classifications mediaType: application/json @@ -679,7 +819,7 @@ projects: operationIdentifier: civil-event.lookup.verify-registration path: generated/artifacts/civil-event--lookup-verify-registration--representation-supervisory.capability.json representationIdentifier: supervisory - sha256: sha256:d5cc6a898eb258f73e9c83828f4dbae6e981c528658dfb23b8f87c70c21a0dd1 + sha256: sha256:29dac05459854acd9d4c3c72a136422a83c9b36e7822824970d80580cc42bb9c visibility: operation-bound - id: civil-event--lookup-verify-registration--representation-supervisory-classifications mediaType: application/json @@ -728,7 +868,7 @@ projects: operationIdentifier: civil-event.read path: generated/artifacts/civil-event--read--representation-registrar.capability.json representationIdentifier: registrar - sha256: sha256:5c49bf297ff58f5e4e1cb2c7cc190471c1d56fb5ec4bfc42f5d62938245eb6a2 + sha256: sha256:611eb052d2cbc4b5a114b437fe56878b9d4315eb9e931048845d2cc0b6a6cb05 visibility: operation-bound - id: civil-event--read--representation-registrar-classifications mediaType: application/json @@ -840,7 +980,7 @@ projects: operationIdentifier: null path: generated/openapi.full.yaml representationIdentifier: null - sha256: sha256:04b9fc444ace2a9e3062f82fdf3e9cbd3d375422c109dd0c3bdd42fe7b8efdf4 + sha256: sha256:96a8a6192ea325eb97a71724272413430acd28fa9e9e54c311360eb4cc735cdf visibility: operator-only - id: openapi-public mediaType: application/json diff --git a/products/relay-v2/contracts/security-invariant-matrix.yaml b/products/relay-v2/contracts/security-invariant-matrix.yaml index 487627dc7..cba46dfaf 100644 --- a/products/relay-v2/contracts/security-invariant-matrix.yaml +++ b/products/relay-v2/contracts/security-invariant-matrix.yaml @@ -207,6 +207,27 @@ invariants: - {path: crates/registry-relay-v2/src/cursor.rs, name: cursor_conceals_order_values_and_refuses_tampering} - {path: crates/registry-relay-v2/src/cursor.rs, name: encrypting_the_same_cursor_twice_uses_distinct_nonces} - {path: crates/registry-relay-v2/src/cursor.rs, name: cursor_refuses_every_mismatched_request_binding_and_expiry} + - {path: crates/registry-relay-v2/src/cursor.rs, name: cursor_cannot_cross_spatial_or_representation_contexts} + - id: sec-spatial-disclosure-confinement + threat: GeoJSON negotiation, an unsafe coordinate row, or an unreviewed coordinate carrier widens disclosure beyond the selected governed representation. + enforcementPoint: Primary-geometry compilation, representation-scoped disclosure, complete Point validation before release, and one shared authorization and disclosure decision across JSON, JSON-LD, and GeoJSON. + negativeTest: geometry_disclosure_is_representation_scoped + expected: GeoJSON is available only when the exact selected representation discloses its classified Point; invalid coordinates fail closed before release and carrier columns never serialize. + evidence: compiler-runtime-and-artifact-spatial-tests + tests: + - {path: crates/registry-relay-v2/src/compiler.rs, name: geometry_disclosure_is_representation_scoped} + - {path: crates/registry-relay-v2/src/artifacts.rs, name: spatial_artifacts_are_deterministic_bounded_and_carrier_free} + - {path: crates/registry-relay-v2/tests/acceptance_http.rs, name: spatial_terminal_audit_failure_discards_held_feature_bytes} + - id: sec-spatial-query-confinement + threat: A caller turns a Point search into a broad geographic scan, antimeridian traversal, source expression, or alternate spatial service. + enforcementPoint: Closed compiled bbox plan with finite CRS84 range, inclusive bounds, no antimeridian crossing, publisher span limits, and bound SQLite coordinates. + negativeTest: spatial_representations_validate_and_keep_distinct_cache_identities + expected: Only one declared Point bbox parameter can narrow an opted list; all other spatial query languages, CRS choices, geometry inputs, and crossing boxes are unavailable. + evidence: spatial-bbox-parser-and-sqlite-plan-tests + tests: + - {path: crates/registry-relay-v2/src/sqlite_runtime.rs, name: point_bbox_validation_is_numeric_and_crs84_bounded} + - {path: crates/registry-relay-v2/src/sqlite_runtime.rs, name: point_bbox_refuses_dateline_crossing} + - {path: crates/registry-relay-v2/tests/acceptance_http.rs, name: spatial_representations_validate_and_keep_distinct_cache_identities} - id: sec-source-truthfulness threat: Relay serves incompatible schema, mixed live state, or false revision and cache claims. enforcementPoint: Schema fingerprint, one transaction per request, source-profile revisions, path identity checks, and per-execution snapshot digest verification before and after query execution. diff --git a/products/relay-v2/scripts/validate_product.py b/products/relay-v2/scripts/validate_product.py index 102053a19..e9b0946fa 100644 --- a/products/relay-v2/scripts/validate_product.py +++ b/products/relay-v2/scripts/validate_product.py @@ -61,6 +61,8 @@ "sec-audit-release-gate", "sec-audit-correlation-and-minimization", "sec-cursor-integrity", + "sec-spatial-disclosure-confinement", + "sec-spatial-query-confinement", "sec-source-truthfulness", "sec-value-free-diagnostics", "sec-value-free-operational-logs", From bc73b883ada3ed1a36c1ebbf04da4296f49ae0b3 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 10 Aug 2026 10:58:24 +0700 Subject: [PATCH 17/24] docs(relay): document governed spatial profile Signed-off-by: Jeremi Joslin --- .../site/src/content/docs/configure/relay.mdx | 45 +++++++++++++++ .../governed-registry-publication.mdx | 18 ++++++ .../relay-semantics-and-disclosure.mdx | 22 +++++++ docs/site/src/content/docs/operate/relay.mdx | 13 ++++- .../src/content/docs/reference/standards.mdx | 10 ++++ .../publish-governed-sqlite-registry.mdx | 30 ++++++++++ docs/site/src/data/generated/standards.json | 57 +++++++++++++++++++ docs/site/src/data/standards.yaml | 39 +++++++++++++ 8 files changed, 231 insertions(+), 3 deletions(-) diff --git a/docs/site/src/content/docs/configure/relay.mdx b/docs/site/src/content/docs/configure/relay.mdx index 03d9aff58..0ef240bca 100644 --- a/docs/site/src/content/docs/configure/relay.mdx +++ b/docs/site/src/content/docs/configure/relay.mdx @@ -13,6 +13,8 @@ standards_referenced: - json-schema - json-ld - shacl + - geojson + - json-fg - govstack-digital-registries --- @@ -137,6 +139,49 @@ It cannot add a property, select a source column, change a transform, weaken han an access or row boundary. Registry Core remains present. +## Add a bounded Point profile + +Declare one `primaryGeometry` when a resource publishes a reviewed longitude-latitude Point in +the Coordinate Reference System 84 (CRS84) order. The geometry is a classified, selectable +property. Its two source columns remain private carrier bindings. + +```yaml +primaryGeometry: + name: location + label: Premises location + description: Reviewed premises Point in CRS84 longitude-latitude order + semanticTerm: local:location + sourceRequired: true + crs: http://www.opengis.net/def/crs/OGC/0/CRS84 + source: {longitudeColumn: longitude, latitudeColumn: latitude} + classification: {privacy: non-personal, institutional: public, handling: public, status: reviewed} +disclosureProfiles: + public-premises: {properties: [premisesName, location]} +operations: + list: + defaultRepresentation: public-premises + representations: + public-premises: {access: public, disclosureProfile: public-premises} + allowUnfiltered: false + spatialQuery: + bbox: {maximumLongitudeSpanDegrees: 2, maximumLatitudeSpanDegrees: 2} + orderBy: [premisesIdentifier] + pagination: {defaultPageSize: 50, maximumPageSize: 200} +``` + +The representation map still owns access and maximum disclosure. JSON and JSON-LD are available +for every selected representation. GeoJSON becomes available only when that representation's +profile includes `location`. `Accept: application/geo+json` selects the wire format, while +`profile=rfc7946` or `profile=jsonfg` selects the GeoJSON profile. Neither grants another access +right or adds a property. + +`spatialQuery.bbox` enables one inclusive, bounded Point-containment query. The complete geometry +and both carrier columns must have effective `privacy: non-personal` classification. Relay refuses +non-finite values, coordinates outside CRS84, decreasing bounds, antimeridian crossing, and spans +larger than the authored limits. It does not expose a spatial expression language, reprojection, +joins, or dynamic SQLite extensions. The [business acceptance contract](https://github.com/registrystack/registry-stack/blob/d7b96d7d4aa89754c0f2ca6fe3d4d697e9e9e8ad/products/relay-v2/acceptance/business-registry/registry.yaml) +is the executable configuration example. + ## Account for processing and disclosure Technical handling is ordered from `public` through `internal` and `confidential` to `restricted`. diff --git a/docs/site/src/content/docs/explanation/governed-registry-publication.mdx b/docs/site/src/content/docs/explanation/governed-registry-publication.mdx index 03e0e4560..1af43dc5a 100644 --- a/docs/site/src/content/docs/explanation/governed-registry-publication.mdx +++ b/docs/site/src/content/docs/explanation/governed-registry-publication.mdx @@ -10,6 +10,8 @@ doc_type: explanation locale: en standards_referenced: - openapi + - geojson + - json-fg - govstack-digital-registries - universal-dpi-safeguards --- @@ -71,6 +73,21 @@ Disclosure handling covers the serializable properties of the representation. The processing floor drives compiler validity, source projection, cache eligibility, and audit context. Authorization remains the representation's explicit access rule. +## Keep spatial output inside the same boundary + +A resource can declare one classified primary Point assembled from reviewed CRS84 longitude and +latitude columns. The Point enters an ordinary disclosure profile by its published property name. +A representation that omits that property cannot negotiate GeoJSON, while a representation that +includes it can serialize the same governed Record as JSON, JSON-LD, RFC 7946 GeoJSON, or the +bounded JSON-FG profile. `Accept` changes the wire format, not the authorization decision. + +A list can add one publisher-bounded `bbox` query over that Point. Relay classifies the capability +as constrained `consultation.search`, binds the predicate to the reviewed columns, and carries the +bbox, selected representation, wire format, and profile inside the encrypted cursor context. +The initial profile has no alternate coordinate reference system, antimeridian traversal, generic +geometry, spatial join, or OGC API Features route. The [spatial acceptance tests](https://github.com/registrystack/registry-stack/blob/d7b96d7d4aa89754c0f2ca6fe3d4d697e9e9e8ad/crates/registry-relay-v2/tests/acceptance_http.rs) +exercise the shared authorization, disclosure, audit, and cache boundary. + ## Offer only declared read capabilities Relay compiles only the operations the publisher declares: @@ -80,6 +97,7 @@ Relay compiles only the operations the publisher declares: | Identifier read | `consultation.retrieve` | One Record by stable identifier. | | Deterministic list | `consultation.list` | A bounded collection with declared equality filters and order. | | Named exact lookup | `consultation.search` | One resolved Record or an indistinguishable unresolved outcome. | +| Bounded Point search | `consultation.search` | A deterministic list constrained by one declared CRS84 bbox. | Exact lookup is not record matching: Relay returns no candidates, scores, rankings, or matching explanations. diff --git a/docs/site/src/content/docs/explanation/relay-semantics-and-disclosure.mdx b/docs/site/src/content/docs/explanation/relay-semantics-and-disclosure.mdx index 302541815..fa79b58b9 100644 --- a/docs/site/src/content/docs/explanation/relay-semantics-and-disclosure.mdx +++ b/docs/site/src/content/docs/explanation/relay-semantics-and-disclosure.mdx @@ -12,6 +12,8 @@ standards_referenced: - json-schema - json-ld - shacl + - geojson + - json-fg - govstack-digital-registries - universal-dpi-safeguards --- @@ -130,6 +132,26 @@ Transformed properties also cannot be list filters or fixed-order keys. Queryabl must be separate reviewed pre-derived source properties, so filters and ordering never operate on an undisclosed transform input. +## Treat geometry as a classified property + +The bounded spatial profile gives one primary Point a public property name, semantic term, +description, source requiredness, and effective classification. Longitude and latitude remain +reviewed source-column bindings. Relay validates both coordinates as one complete Point before +releasing any response, and a malformed selected row fails closed as value-free +`503 source.unavailable`. + +The selected representation controls whether the Point is disclosed. Ordinary JSON and JSON-LD +place the Point in `domainData`. GeoJSON moves the same selected Point to the Feature `geometry` +member and leaves Registry Core plus the other selected properties in Feature `properties`. +If the caller narrows `fields` to omit the Point, GeoJSON uses an explicit `null` geometry rather +than reintroducing the coordinates elsewhere. + +Generated vocabulary, JSON Schema, SHACL, classification, and capability artifacts describe the +Point only for audiences that can receive the selected representation. The JSON-LD context types +the bounded GeoJSON value as RDF JSON. Relay does not claim GeoSPARQL inference, geometry +conversion, or an OGC API Features service. The [generated artifact tests](https://github.com/registrystack/registry-stack/blob/d7b96d7d4aa89754c0f2ca6fe3d4d697e9e9e8ad/crates/registry-relay-v2/src/artifacts.rs) +pin those limits. + ## Generate semantics without pretending equivalence Relay generates a local JSON for Linked Data (JSON-LD) vocabulary and context, JSON Schema, SHACL, diff --git a/docs/site/src/content/docs/operate/relay.mdx b/docs/site/src/content/docs/operate/relay.mdx index d409c3c07..aa9e8b145 100644 --- a/docs/site/src/content/docs/operate/relay.mdx +++ b/docs/site/src/content/docs/operate/relay.mdx @@ -8,7 +8,9 @@ source_repos: last_reviewed: "2026-08-10" doc_type: how-to locale: en -standards_referenced: [] +standards_referenced: + - geojson + - json-fg --- Deploy one reviewed Relay package without allowing deployment configuration to change Registry @@ -72,8 +74,9 @@ fall back to a less restrictive representation. Relay authenticates and encrypts the complete cursor payload with a fresh nonce. The payload binds the source and contract revisions, operation, representation, disclosure profile, filters, fixed -order, selected fields, authorization context, and expiry. Treat cursors as opaque continuation -tokens even though they contain no plaintext filter or order values. +order, selected fields, authorization context, optional bbox, response format, GeoJSON profile, +and expiry. Treat cursors as opaque continuation tokens even though they contain no plaintext +filter, order, or bbox values. `relay serve --runtime ` opens only the sealed package at `packagePath`. It rejects unsafe paths, a package inventory mismatch, source-schema drift, missing mandatory audit @@ -103,6 +106,9 @@ Runtime cannot change it. Both profiles pin the SQLite schema fingerprint and deny writes, arbitrary SQL, schema drift, and unbounded result behavior. Live resources support only identifier read and named exact lookup. +Bounded bbox consultation is a list operation, so it uses a reviewed snapshot source in this +profile. GeoJSON and JSON-FG do not change the source, access, audit, or cache rules. Their exact +response bytes receive distinct cache validators where public snapshot revalidation is eligible. ## Start and verify the service @@ -163,4 +169,5 @@ Rollback activates a complete prior package only with its compatible source and | Startup refuses a path | A component is a symlink, has the wrong owner, or is writable | Move the deployment to trusted Unix paths and correct ownership and modes. | | Startup reports a package or schema error | The package, source, or review revision does not match | Stop deployment and return the change to the authoring workflow. | | A protected representation returns `404 resource.not_found` | The issuer or token does not satisfy that representation's exact scope | Correct the issuer or caller authority. Do not expose a weaker representation as fallback. | +| A spatial request returns `406 representation.unsupported` | The selected representation does not disclose a primary geometry or the `Accept` value is unsupported | Select an entitled geometry-bearing representation or request JSON or JSON-LD. | | A response is withheld | The terminal audit write failed | Restore the audit sink and verify its integrity before accepting traffic. | diff --git a/docs/site/src/content/docs/reference/standards.mdx b/docs/site/src/content/docs/reference/standards.mdx index 5fd59af89..8c61a8234 100644 --- a/docs/site/src/content/docs/reference/standards.mdx +++ b/docs/site/src/content/docs/reference/standards.mdx @@ -18,6 +18,8 @@ standards_referenced: - ogc-api-records - ogc-api-features - ogc-api-edr + - geojson + - json-fg - openapi - shacl - skos @@ -59,6 +61,14 @@ Aggregate output supports JSON, CSV, and SDMX-JSON, with only JSON stable for 1. +## Relay V2 spatial representation profile + +Relay V2 uses GeoJSON and JSON-FG as optional representations of the same +governed Registry Record. The initial profile is one classified Point in +CRS84, with an exact bounded bounding-box query on an existing consultation +route. This does not claim OGC API Features support and does not inherit the +separate Relay 1.0 adapter described in the roster. + ## Register diff --git a/docs/site/src/content/docs/tutorials/publish-governed-sqlite-registry.mdx b/docs/site/src/content/docs/tutorials/publish-governed-sqlite-registry.mdx index 80e50d650..9c243b37b 100644 --- a/docs/site/src/content/docs/tutorials/publish-governed-sqlite-registry.mdx +++ b/docs/site/src/content/docs/tutorials/publish-governed-sqlite-registry.mdx @@ -16,6 +16,8 @@ standards_referenced: - json-schema - json-ld - shacl + - geojson + - json-fg --- import QuickstartMeta from '../../../components/QuickstartMeta.astro'; @@ -244,6 +246,32 @@ request, and an unknown representation. The unknown and scope-hidden cases use t `404 resource.not_found` response. Relay authorizes the requested representation exactly and never falls back to the public default. +## Verify the spatial response boundary + +The supplied project also contains a `registered-premises` resource with one reviewed Point and a +bounded bbox query. Replay its JSON-FG fixture: + +```sh +relayctl test "$project" --fixture premises-feature-collection-jsonfg +``` + +The report contains one successful step: + +```json +{ + "id": "premises-feature-collection-jsonfg", + "expectedStatus": 200, + "actualStatus": 200, + "passed": true +} +``` + +The request uses `Accept: application/geo+json`, `profile=jsonfg`, and a bounded CRS84 `bbox`. +Those values select a wire format and a fixed query plan. The `public-premises` representation +still supplies the access rule and maximum disclosure profile. The full journey also proves RFC +7946 output, JSON and JSON-LD equivalence, field minimization to `geometry: null`, invalid bbox +refusal, cursor binding, nonspatial negotiation refusal, and value-free coordinate failure. + Starting the packaged service requires the configured token issuer to be reachable because the package contains protected representations, even when the request you plan to send is public. Continue with [Operate Registry Relay](../../operate/relay/) to bind a real institutional issuer, @@ -268,6 +296,8 @@ The cleanup commands print nothing when they succeed. - Generated identification and classification material remained suggestions until the governed review accepted it. - The caller selected fewer properties but could not select more. +- The Point used the same operation access and disclosure boundary in JSON, + JSON-LD, RFC 7946 GeoJSON, and JSON-FG. - Mandatory Registry and Record context remained present in every result. - The shared Relay router verified the public, protected, and refusal behavior against synthetic source data. diff --git a/docs/site/src/data/generated/standards.json b/docs/site/src/data/generated/standards.json index c1f77ee42..69bba663d 100644 --- a/docs/site/src/data/generated/standards.json +++ b/docs/site/src/data/generated/standards.json @@ -182,6 +182,63 @@ "last_checked": "2026-07-19", "notes": "Relay exposes OGC API EDR area routes behind the ogcapi-edr feature for configured spatial aggregates. The adapter is experimental, feature-frozen, and outside the 1.0 compatibility promise. The claim is scoped to the tested adapter surface, not full OGC conformance." }, + { + "id": "geojson", + "name": "GeoJSON", + "standards_body": "Internet Engineering Task Force", + "official_url": "https://www.rfc-editor.org/rfc/rfc7946", + "version_or_profile": "RFC 7946 Point, Feature, and FeatureCollection profile", + "status": "used", + "claim_level": "emits", + "adoption_mode": "profiled", + "used_by": [ + "registry-relay" + ], + "surfaces": [ + "Relay V2 governed Point responses", + "Relay V2 bounded Point collection responses" + ], + "evidence_docs": [ + { + "label": "Relay V2 spatial response acceptance tests", + "url": "https://github.com/registrystack/registry-stack/blob/545efaf9597647744ada29fdb43759a4d1fac996/crates/registry-relay-v2/tests/acceptance_http.rs" + }, + { + "label": "Relay V2 generated GeoJSON schema tests", + "url": "https://github.com/registrystack/registry-stack/blob/545efaf9597647744ada29fdb43759a4d1fac996/crates/registry-relay-v2/src/artifacts.rs" + } + ], + "last_checked": "2026-08-10", + "notes": "Relay V2 emits RFC 7946-shaped Point Features and FeatureCollections only when the selected governed representation discloses the primary geometry. The profile uses exact CRS84 longitude-latitude coordinates and does not claim an OGC API Features service." + }, + { + "id": "json-fg", + "name": "OGC Features and Geometries JSON", + "standards_body": "Open Geospatial Consortium", + "official_url": "https://docs.ogc.org/is/21-045r1/21-045r1.html", + "version_or_profile": "JSON-FG 1.0 Core and Types-Schemas profile over an RFC 7946 Point", + "status": "used", + "claim_level": "emits", + "adoption_mode": "profiled", + "used_by": [ + "registry-relay" + ], + "surfaces": [ + "Relay V2 governed JSON-FG Point responses" + ], + "evidence_docs": [ + { + "label": "Relay V2 spatial response acceptance tests", + "url": "https://github.com/registrystack/registry-stack/blob/545efaf9597647744ada29fdb43759a4d1fac996/crates/registry-relay-v2/tests/acceptance_http.rs" + }, + { + "label": "Relay V2 generated GeoJSON schema tests", + "url": "https://github.com/registrystack/registry-stack/blob/545efaf9597647744ada29fdb43759a4d1fac996/crates/registry-relay-v2/src/artifacts.rs" + } + ], + "last_checked": "2026-08-10", + "notes": "Relay V2 adds the JSON-FG Core and Types-Schemas identifiers plus a governed feature type to the same Point representation. It does not implement extended JSON-FG geometries or a generic feature API." + }, { "id": "openapi", "name": "OpenAPI", diff --git a/docs/site/src/data/standards.yaml b/docs/site/src/data/standards.yaml index 48865ae48..3b0931f11 100644 --- a/docs/site/src/data/standards.yaml +++ b/docs/site/src/data/standards.yaml @@ -125,6 +125,45 @@ url: https://github.com/registrystack/registry-stack/blob/d45761a0104bd3d9c2e4b4db391d4223f289bd44/crates/registry-relay/tests/ogc_edr_api.rs last_checked: 2026-07-19 notes: Relay exposes OGC API EDR area routes behind the ogcapi-edr feature for configured spatial aggregates. The adapter is experimental, feature-frozen, and outside the 1.0 compatibility promise. The claim is scoped to the tested adapter surface, not full OGC conformance. +- id: geojson + name: GeoJSON + standards_body: Internet Engineering Task Force + official_url: https://www.rfc-editor.org/rfc/rfc7946 + version_or_profile: RFC 7946 Point, Feature, and FeatureCollection profile + status: used + claim_level: emits + adoption_mode: profiled + used_by: + - registry-relay + surfaces: + - Relay V2 governed Point responses + - Relay V2 bounded Point collection responses + evidence_docs: + - label: Relay V2 spatial response acceptance tests + url: https://github.com/registrystack/registry-stack/blob/545efaf9597647744ada29fdb43759a4d1fac996/crates/registry-relay-v2/tests/acceptance_http.rs + - label: Relay V2 generated GeoJSON schema tests + url: https://github.com/registrystack/registry-stack/blob/545efaf9597647744ada29fdb43759a4d1fac996/crates/registry-relay-v2/src/artifacts.rs + last_checked: 2026-08-10 + notes: Relay V2 emits RFC 7946-shaped Point Features and FeatureCollections only when the selected governed representation discloses the primary geometry. The profile uses exact CRS84 longitude-latitude coordinates and does not claim an OGC API Features service. +- id: json-fg + name: OGC Features and Geometries JSON + standards_body: Open Geospatial Consortium + official_url: https://docs.ogc.org/is/21-045r1/21-045r1.html + version_or_profile: JSON-FG 1.0 Core and Types-Schemas profile over an RFC 7946 Point + status: used + claim_level: emits + adoption_mode: profiled + used_by: + - registry-relay + surfaces: + - Relay V2 governed JSON-FG Point responses + evidence_docs: + - label: Relay V2 spatial response acceptance tests + url: https://github.com/registrystack/registry-stack/blob/545efaf9597647744ada29fdb43759a4d1fac996/crates/registry-relay-v2/tests/acceptance_http.rs + - label: Relay V2 generated GeoJSON schema tests + url: https://github.com/registrystack/registry-stack/blob/545efaf9597647744ada29fdb43759a4d1fac996/crates/registry-relay-v2/src/artifacts.rs + last_checked: 2026-08-10 + notes: Relay V2 adds the JSON-FG Core and Types-Schemas identifiers plus a governed feature type to the same Point representation. It does not implement extended JSON-FG geometries or a generic feature API. - id: openapi name: OpenAPI standards_body: OpenAPI Initiative From 706bdd0f85fe6caf29b963016f46ecc44326e184 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 10 Aug 2026 17:19:47 +0700 Subject: [PATCH 18/24] feat(relay): clarify governed access and spatial search Signed-off-by: Jeremi Joslin --- crates/registry-relay-v2/src/api.rs | 501 ++++---- crates/registry-relay-v2/src/artifacts.rs | 435 ++++--- crates/registry-relay-v2/src/audit.rs | 45 +- crates/registry-relay-v2/src/compiler.rs | 776 ++++++++---- crates/registry-relay-v2/src/contract.rs | 80 +- crates/registry-relay-v2/src/cursor.rs | 35 +- crates/registry-relay-v2/src/diff.rs | 216 ++-- .../registry-relay-v2/src/fixture_contract.rs | 4 +- crates/registry-relay-v2/src/fixtures.rs | 52 +- .../src/format_capabilities.rs | 150 +++ .../registry-relay-v2/src/identification.rs | 1089 +++++++++++++++-- crates/registry-relay-v2/src/lib.rs | 1 + crates/registry-relay-v2/src/model.rs | 9 +- crates/registry-relay-v2/src/package.rs | 71 +- crates/registry-relay-v2/src/problem.rs | 31 +- crates/registry-relay-v2/src/semantics.rs | 8 +- crates/registry-relay-v2/src/server.rs | 25 + .../registry-relay-v2/src/sqlite_runtime.rs | 221 ++-- crates/registry-relay-v2/src/startup.rs | 51 +- crates/registry-relay-v2/src/tooling.rs | 207 +++- .../tests/acceptance_http.rs | 202 ++- ...ntation_http.rs => access_profile_http.rs} | 122 +- .../registry-relay-v2/tests/identification.rs | 33 +- .../tests/multi_resource_isolation.rs | 38 +- .../registry-relay-v2/tests/process_http.rs | 42 +- crates/registry-relayctl/INTEGRATION.md | 12 +- crates/registry-relayctl/src/lib.rs | 176 ++- crates/registry-relayctl/src/shared.rs | 1 + .../registry-relayctl/tests/cli_contract.rs | 94 ++ products/relay-v2/CONCEPT.md | 118 +- products/relay-v2/CONFIGURATION-EXAMPLES.md | 279 +++-- products/relay-v2/DEFINITION-OF-DONE.md | 63 +- products/relay-v2/IMPLEMENTATION.md | 124 +- products/relay-v2/README.md | 8 +- products/relay-v2/STANDARDS-ALIGNMENT.md | 7 +- .../business-registry/expected-http.yaml | 154 ++- .../classification-review-rationale.md | 2 +- .../governance/classification-review.yaml | 2 +- .../business-registry/registry.yaml | 45 +- .../acceptance/civil-event/expected-http.yaml | 12 +- .../classification-review-rationale.md | 2 +- .../governance/classification-review.yaml | 2 +- .../acceptance/civil-event/registry.yaml | 8 +- .../social-assistance/expected-http.yaml | 18 +- .../classification-review-rationale.md | 2 +- .../governance/classification-review.yaml | 2 +- .../social-assistance/registry.yaml | 4 +- .../contracts/acceptance-scenario-matrix.yaml | 44 +- .../contracts/artifact-inventory.yaml | 23 +- .../contracts/generated-baselines.yaml | 968 +++++++++------ .../contracts/security-invariant-matrix.yaml | 71 +- .../relay-v2/scripts/test_adopter_workflow.py | 60 +- .../scripts/test_adopter_workflow_openapi.py | 12 +- .../relay-v2/scripts/test_validate_product.py | 54 +- products/relay-v2/scripts/validate_product.py | 207 +++- 55 files changed, 4832 insertions(+), 2186 deletions(-) create mode 100644 crates/registry-relay-v2/src/format_capabilities.rs rename crates/registry-relay-v2/tests/{representation_http.rs => access_profile_http.rs} (90%) diff --git a/crates/registry-relay-v2/src/api.rs b/crates/registry-relay-v2/src/api.rs index b305f69c9..932706e0f 100644 --- a/crates/registry-relay-v2/src/api.rs +++ b/crates/registry-relay-v2/src/api.rs @@ -24,9 +24,13 @@ use crate::cursor::{ decode as decode_cursor, encode as encode_cursor, now_unix_seconds, require_same_request, CursorBindings, CursorPayload, CursorValue, }; +use crate::format_capabilities::{ + response_format_capabilities, supports_geojson, CRS84_URI, JSON_FG_CORE_CONFORMANCE, + JSON_FG_PROFILE_URI, JSON_FG_TYPES_CONFORMANCE, RFC7946_PROFILE_URI, +}; use crate::model::{ - CompiledAccess, CompiledOperation, CompiledRepresentation, CompiledResource, - ConsultationPattern, OperationKind, RepresentationProfile, RowAuthoritySource, + CompiledAccess, CompiledAccessProfile, CompiledOperation, CompiledResource, + ConsultationPattern, OperationKind, RowAuthoritySource, }; use crate::problem::{ProblemCode, TraceContext}; use crate::server::{uri_within_bound, RelayService}; @@ -40,12 +44,6 @@ const API_BINDING_VERSION: &str = "v2"; const METADATA_DEFAULT_PAGE_SIZE: usize = 50; const METADATA_MAXIMUM_PAGE_SIZE: usize = 100; const MAXIMUM_SERIALIZED_RESPONSE_BYTES: usize = 8 * 1024 * 1024; -const JSON_FG_PROFILE_URI: &str = "http://www.opengis.net/def/profile/OGC/0/jsonfg"; -const RFC_7946_PROFILE_URI: &str = "http://www.opengis.net/def/profile/OGC/0/rfc7946"; -const CRS84_URI: &str = "http://www.opengis.net/def/crs/OGC/0/CRS84"; -const JSON_FG_CORE_CONFORMANCE: &str = "http://www.opengis.net/spec/json-fg-1/1.0/conf/core"; -const JSON_FG_TYPES_SCHEMAS_CONFORMANCE: &str = - "http://www.opengis.net/spec/json-fg-1/1.0/conf/types-schemas"; #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum ResponseFormat { @@ -88,7 +86,7 @@ impl ResponseFormat { const fn profile_link(self) -> Option<&'static str> { match self { Self::GeoJson(GeoJsonProfile::JsonFg) => Some(JSON_FG_PROFILE_URI), - Self::GeoJson(GeoJsonProfile::Rfc7946) => Some(RFC_7946_PROFILE_URI), + Self::GeoJson(GeoJsonProfile::Rfc7946) => Some(RFC7946_PROFILE_URI), Self::Json | Self::JsonLd => None, } } @@ -98,7 +96,7 @@ impl ResponseFormat { struct Access { principal: Option, authorization: Authorization, - representation: CompiledRepresentation, + access_profile: CompiledAccessProfile, } pub async fn health() -> Response { @@ -156,8 +154,8 @@ pub async fn service_metadata( Err(ProblemCode::MissingCredential) => Vec::new(), Err(code) => return code.response(&trace), }; - capabilities.extend(operations.into_iter().map(|(operation, representation)| { - capability(&service, resource, operation, representation) + capabilities.extend(operations.into_iter().map(|(operation, access_profile)| { + capability(&service, resource, operation, access_profile) })); } } @@ -389,14 +387,14 @@ pub async fn artifact( let Some(operation) = find_operation_by_id(&service, identifier) else { return ProblemCode::ResourceNotFound.response(&trace); }; - let Some(representation_identifier) = artifact.representation_identifier.as_deref() + let Some(access_profile_identifier) = artifact.access_profile_identifier.as_deref() else { return ProblemCode::ResourceNotFound.response(&trace); }; - let Some(representation) = operation - .representations + let Some(access_profile) = operation + .access_profiles .iter() - .find(|representation| representation.id == representation_identifier) + .find(|access_profile| access_profile.id == access_profile_identifier) else { return ProblemCode::ResourceNotFound.response(&trace); }; @@ -404,7 +402,7 @@ pub async fn artifact( return ProblemCode::ResourceNotFound.response(&trace); }; if authenticator - .authorize(&representation.access, Some(principal)) + .authorize(&access_profile.access, Some(principal)) .is_err() { return ProblemCode::ResourceNotFound.response(&trace); @@ -437,8 +435,42 @@ pub async fn record_list( return unknown_data_route(&service, principal.as_ref(), &trace, OperationClass::List) .await; }; - let access = match access_operation( + record_collection(&service, resource, operation, principal, headers, uri).await +} + +pub async fn record_search( + State(service): State>, + Path((resource_id, search_id)): Path<(String, String)>, + headers: HeaderMap, + uri: Uri, +) -> Response { + let trace = TraceContext::from_headers(&headers); + let principal = match authenticate_data_request(&service, &headers, &trace).await { + Ok(value) => value, + Err(response) => return response, + }; + let Some((resource, operation)) = find_operation( &service, + &resource_id, + |kind| matches!(kind, OperationKind::Search { name } if name == &search_id), + ) else { + return unknown_data_route(&service, principal.as_ref(), &trace, OperationClass::Search) + .await; + }; + record_collection(&service, resource, operation, principal, headers, uri).await +} + +async fn record_collection( + service: &RelayService, + resource: &CompiledResource, + operation: &CompiledOperation, + principal: Option, + headers: HeaderMap, + uri: Uri, +) -> Response { + let trace = TraceContext::from_headers(&headers); + let access = match access_operation( + service, resource, operation, uri.query(), @@ -452,7 +484,7 @@ pub async fn record_list( }; if !uri_within_bound(&uri) { return refuse_known( - &service, + service, resource, operation, Some(&access), @@ -464,7 +496,7 @@ pub async fn record_list( } if rejects_caller_purpose(&headers) { return refuse_known( - &service, + service, resource, operation, Some(&access), @@ -474,11 +506,11 @@ pub async fn record_list( ) .await; } - let response_format = match negotiate(&headers, resource, &access.representation) { + let response_format = match negotiate(&headers, resource, &access.access_profile) { Ok(value) => value, Err(code) => { return refuse_known( - &service, + service, resource, operation, Some(&access), @@ -489,8 +521,8 @@ pub async fn record_list( .await } }; - let query = match prepare_list( - &service, + let query = match prepare_collection( + service, resource, operation, &access, @@ -500,7 +532,7 @@ pub async fn record_list( Ok(value) => value, Err(code) => { return refuse_known( - &service, + service, resource, operation, Some(&access), @@ -512,7 +544,7 @@ pub async fn record_list( } }; if let Some(response) = quota_refusal( - &service, + service, resource, operation, &access, @@ -524,7 +556,7 @@ pub async fn record_list( return response; } let audit = audit_context( - &service, + service, resource, operation, Some(&access), @@ -538,7 +570,7 @@ pub async fn record_list( .sqlite .execute( &operation.identifier, - &access.representation.id, + &access.access_profile.id, OperationQuery { filters: query.filters.clone(), row_authority: access.authorization.row_authority.clone(), @@ -564,9 +596,9 @@ pub async fn record_list( return source_shape_failure(&service.audit, &audit, &trace).await; } let record = match record_value( - &service, + service, resource, - &access.representation, + &access.access_profile, row, &query.selected_fields, ) { @@ -592,7 +624,7 @@ pub async fn record_list( .await; }; match next_cursor( - &service, + service, operation, &access, &query, @@ -606,16 +638,16 @@ pub async fn record_list( None }; let meta = record_meta( - &service, + service, resource, operation, - &access.representation, + &access.access_profile, &query.selected_fields, &result.source_revision, ); let mut document = match query.response_format { ResponseFormat::GeoJson(profile) => { - geojson_collection(&service, resource, items, next_cursor, meta, profile) + geojson_collection(service, resource, items, next_cursor, meta, profile) } ResponseFormat::Json | ResponseFormat::JsonLd => json!({ "items": items, @@ -624,18 +656,18 @@ pub async fn record_list( }), }; apply_json_ld( - &service, + service, resource, - &access.representation, + &access.access_profile, query.response_format, &mut document, ); release_document( - &service, + service, &audit, document, query.response_format, - cacheable(&access.representation, &result.source_revision), + cacheable(&access.access_profile, &result.source_revision), &headers, &trace, ) @@ -774,7 +806,7 @@ pub async fn record_lookup( let (response_format, fields) = match prepare_single_request( resource, operation, - &access.representation, + &access.access_profile, request.headers(), request.uri().query(), ) { @@ -926,7 +958,7 @@ async fn single_operation( let (representation, fields) = match prepare_single_request( resource, operation, - &access.representation, + &access.access_profile, headers, request.query_text, ) { @@ -970,7 +1002,7 @@ async fn single_operation( .sqlite .execute( &operation.identifier, - &access.representation.id, + &access.access_profile.id, request.query, ) .await; @@ -992,7 +1024,7 @@ async fn single_operation( let record = match record_value( service, resource, - &access.representation, + &access.access_profile, &result.rows[0], &fields, ) { @@ -1005,7 +1037,7 @@ async fn single_operation( service, resource, operation, - &access.representation, + &access.access_profile, &fields, &result.source_revision, ); @@ -1021,7 +1053,7 @@ async fn single_operation( apply_json_ld( service, resource, - &access.representation, + &access.access_profile, representation, &mut document, ); @@ -1030,7 +1062,7 @@ async fn single_operation( &audit, document, representation, - cacheable(&access.representation, &result.source_revision), + cacheable(&access.access_profile, &result.source_revision), headers, trace, ) @@ -1045,7 +1077,7 @@ async fn access_operation( principal: Option, trace: &TraceContext, ) -> Result> { - let selected = match select_representation(operation, query) { + let selected = match select_access_profile(operation, query) { Ok(value) => value, Err(ProblemCode::ResourceNotFound) => { return Err(refuse_unknown( @@ -1058,7 +1090,7 @@ async fn access_operation( .await); } Err(code) => { - return Err(refuse_before_representation( + return Err(refuse_before_access_profile( service, resource, operation, @@ -1070,11 +1102,11 @@ async fn access_operation( .await); } }; - let representation = selected.representation; + let access_profile = selected.access_profile; let explicit = selected.explicit; let authorization = match &service.authenticator { - Some(authenticator) => authenticator.authorize(&representation.access, principal.as_ref()), - None => match representation.access { + Some(authenticator) => authenticator.authorize(&access_profile.access, principal.as_ref()), + None => match access_profile.access { CompiledAccess::Public => Ok(Authorization { row_authority: None, purpose: None, @@ -1086,7 +1118,7 @@ async fn access_operation( Ok(authorization) => Ok(Access { principal, authorization, - representation: representation.clone(), + access_profile: access_profile.clone(), }), Err(error) => { if error == AuthorizationError::AuthenticationRequired && explicit { @@ -1126,7 +1158,7 @@ async fn access_operation( row_authority: None, purpose: None, }, - representation: representation.clone(), + access_profile: access_profile.clone(), }; Err(refuse_known( service, @@ -1199,6 +1231,7 @@ enum OperationClass { List, Read, Lookup, + Search, } async fn unknown_data_route( @@ -1210,8 +1243,8 @@ async fn unknown_data_route( let protected = service.registry.resources.iter().any(|resource| { resource.operations.iter().any(|operation| { class_matches(&operation.kind, class) - && operation.representations.iter().any(|representation| { - matches!(representation.access, CompiledAccess::Protected { .. }) + && operation.access_profiles.iter().any(|access_profile| { + matches!(access_profile.access, CompiledAccess::Protected { .. }) }) }) }); @@ -1245,6 +1278,7 @@ fn class_matches(kind: &OperationKind, class: OperationClass) -> bool { (OperationKind::List, OperationClass::List) | (OperationKind::Read, OperationClass::Read) | (OperationKind::Lookup { .. }, OperationClass::Lookup) + | (OperationKind::Search { .. }, OperationClass::Search) ) } @@ -1278,7 +1312,7 @@ async fn refuse_known( code.response(trace) } -async fn refuse_before_representation( +async fn refuse_before_access_profile( service: &RelayService, resource: &CompiledResource, operation: &CompiledOperation, @@ -1343,21 +1377,21 @@ fn audit_context( registry_identifier: service.registry.registry_identifier.clone(), resource_identifier: Some(resource.id.clone()), operation_identifier: Some(operation.identifier.clone()), - access_rule_revision: access.map(|access| access_revision(&access.representation)), + access_rule_revision: access.map(|access| access_revision(&access.access_profile)), purpose: access.and_then(|access| access.authorization.purpose.clone()), row_boundary_kind: access.map_or(RowBoundaryKind::Unknown, |access| { - row_boundary(&access.representation) + row_boundary(&access.access_profile) }), - representation: access.map(|access| access.representation.id.clone()), - disclosure_profile: access.map(|access| access.representation.disclosure_profile.clone()), + access_profile: access.map(|access| access.access_profile.id.clone()), + disclosure_profile: access.map(|access| access.access_profile.disclosure_profile.clone()), processing_description_identifiers: processing_description_identifiers(resource, operation), selected_properties, processing_handling: access - .map(|access| handling_label(access.representation.processing_handling).into()), + .map(|access| handling_label(access.access_profile.processing_handling).into()), disclosure_handling: access - .map(|access| handling_label(access.representation.disclosure_handling).into()), + .map(|access| handling_label(access.access_profile.disclosure_handling).into()), transform_identifiers: access.map_or_else(Vec::new, |access| { - transform_identifiers(&access.representation) + transform_identifiers(&access.access_profile) }), contract_revision: service.registry.contract_revision.clone(), source_revision: service @@ -1392,7 +1426,7 @@ fn unknown_audit_context( access_rule_revision: None, purpose: None, row_boundary_kind: RowBoundaryKind::Unknown, - representation: None, + access_profile: None, disclosure_profile: None, processing_description_identifiers: Vec::new(), selected_properties: Vec::new(), @@ -1413,6 +1447,7 @@ fn processing_description_identifiers( OperationKind::List => "list".to_owned(), OperationKind::Read => "read".to_owned(), OperationKind::Lookup { name } => format!("lookup:{name}"), + OperationKind::Search { name } => format!("search:{name}"), }; resource .processing_descriptions @@ -1424,15 +1459,15 @@ fn processing_description_identifiers( .collect() } -fn access_revision(representation: &CompiledRepresentation) -> String { - let value = serde_json::to_value(&representation.access) - .expect("compiled representation access serializes"); - let bytes = canonicalize_json(&value).expect("compiled representation access canonicalizes"); +fn access_revision(access_profile: &CompiledAccessProfile) -> String { + let value = serde_json::to_value(&access_profile.access) + .expect("compiled access-profile rule serializes"); + let bytes = canonicalize_json(&value).expect("compiled access-profile rule canonicalizes"); format!("sha256:{}", hex::encode(Sha256::digest(bytes))) } -fn transform_identifiers(representation: &CompiledRepresentation) -> Vec { - representation +fn transform_identifiers(access_profile: &CompiledAccessProfile) -> Vec { + access_profile .transform_inventory .iter() .filter_map(|entry| { @@ -1445,8 +1480,8 @@ fn transform_identifiers(representation: &CompiledRepresentation) -> Vec .collect() } -fn row_boundary(representation: &CompiledRepresentation) -> RowBoundaryKind { - match &representation.access { +fn row_boundary(access_profile: &CompiledAccessProfile) -> RowBoundaryKind { + match &access_profile.access { CompiledAccess::Protected { row_binding: Some(binding), .. @@ -1470,7 +1505,7 @@ fn handling_label(value: Handling) -> &'static str { } } -struct PreparedList { +struct PreparedCollection { page_size: u32, filters: BTreeMap, selected_fields: Vec, @@ -1487,42 +1522,42 @@ struct CursorQueryContext<'a> { response_format: ResponseFormat, } -struct SelectedRepresentation<'a> { - representation: &'a CompiledRepresentation, +struct SelectedAccessProfile<'a> { + access_profile: &'a CompiledAccessProfile, explicit: bool, } -fn select_representation<'a>( +fn select_access_profile<'a>( operation: &'a CompiledOperation, query: Option<&str>, -) -> Result, ProblemCode> { - let requested = representation_parameter(query)?; +) -> Result, ProblemCode> { + let requested = access_profile_parameter(query)?; let identifier = requested .as_deref() - .unwrap_or(&operation.default_representation); - if !valid_representation_identifier(identifier) { - return Err(ProblemCode::RepresentationInvalid); + .unwrap_or(&operation.default_access_profile); + if !valid_access_profile_identifier(identifier) { + return Err(ProblemCode::AccessProfileInvalid); } let explicit = requested.is_some(); operation - .representations + .access_profiles .iter() - .find(|representation| representation.id == identifier) - .map(|representation| SelectedRepresentation { - representation, + .find(|access_profile| access_profile.id == identifier) + .map(|access_profile| SelectedAccessProfile { + access_profile, explicit, }) .ok_or(ProblemCode::ResourceNotFound) } -/// Extract only the representation selector before URI-shape refusal. +/// Extract only the access-profile selector before URI-shape refusal. /// /// This scans the already-buffered query in place and decodes only bounded -/// candidate names and the one bounded representation value. It therefore +/// candidate names and the one bounded access-profile value. It therefore /// preserves exact-profile authorization for an oversized URI without /// allocating or decoding unrelated attacker-controlled query values. -fn representation_parameter(query: Option<&str>) -> Result, ProblemCode> { - const MAXIMUM_ENCODED_NAME_BYTES: usize = "representation".len() * 3; +fn access_profile_parameter(query: Option<&str>) -> Result, ProblemCode> { + const MAXIMUM_ENCODED_NAME_BYTES: usize = "accessProfile".len() * 3; const MAXIMUM_ENCODED_VALUE_BYTES: usize = 128 * 3; let Some(query) = query else { @@ -1540,14 +1575,14 @@ fn representation_parameter(query: Option<&str>) -> Result, Probl continue; } let name = decode_bounded_query_component(raw_name, MAXIMUM_ENCODED_NAME_BYTES)?; - if name != "representation" { + if name != "accessProfile" { continue; } if requested.is_some() || raw_value.len() > MAXIMUM_ENCODED_VALUE_BYTES || raw_value.contains('=') { - return Err(ProblemCode::RepresentationInvalid); + return Err(ProblemCode::AccessProfileInvalid); } requested = Some(decode_bounded_query_component( raw_value, @@ -1562,15 +1597,15 @@ fn decode_bounded_query_component( maximum_encoded_bytes: usize, ) -> Result { if raw.len() > maximum_encoded_bytes || !valid_percent_encoding(raw.as_bytes()) { - return Err(ProblemCode::RepresentationInvalid); + return Err(ProblemCode::AccessProfileInvalid); } url::form_urlencoded::parse(raw.as_bytes()) .next() .map(|(value, _)| value.into_owned()) - .ok_or(ProblemCode::RepresentationInvalid) + .ok_or(ProblemCode::AccessProfileInvalid) } -fn valid_representation_identifier(value: &str) -> bool { +fn valid_access_profile_identifier(value: &str) -> bool { !value.is_empty() && value.len() <= 128 && !value.starts_with('-') @@ -1581,14 +1616,14 @@ fn valid_representation_identifier(value: &str) -> bool { .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') } -fn prepare_list( +fn prepare_collection( service: &RelayService, resource: &CompiledResource, operation: &CompiledOperation, access: &Access, negotiated: ResponseFormat, query: Option<&str>, -) -> Result { +) -> Result { let parameters = parse_query(query)?; let cursors = parameters .iter() @@ -1603,7 +1638,7 @@ fn prepare_list( if cursors.len() != 1 || parameters .iter() - .any(|(name, _)| name != "cursor" && name != "representation") + .any(|(name, _)| name != "cursor" && name != "accessProfile") { return Err(ProblemCode::CursorInvalid); } @@ -1634,11 +1669,12 @@ fn prepare_list( validate_selected_inventory( resource, operation, - &access.representation, + &access.access_profile, &payload.selected_fields, )?; let response_format = - response_format_from_cursor(resource, &access.representation, &payload)?; + response_format_from_cursor(resource, &access.access_profile, &payload) + .map_err(|_| ProblemCode::CursorInvalid)?; if response_format.cursor_kind() != negotiated.cursor_kind() { return Err(ProblemCode::CursorInvalid); } @@ -1660,7 +1696,7 @@ fn prepare_list( }, )?; require_same_request(&payload, &request).map_err(|_| ProblemCode::CursorInvalid)?; - return Ok(PreparedList { + return Ok(PreparedCollection { page_size: payload.page_size, filters, selected_fields: payload.selected_fields, @@ -1679,7 +1715,7 @@ fn prepare_list( let mut page_size = pagination.default_page_size; let mut page_size_seen = false; let mut fields_text = None; - let mut profile_text = None; + let mut format_profile_text = None; let mut bbox_text = None; let declared = operation .query @@ -1706,9 +1742,9 @@ fn prepare_list( return Err(ProblemCode::FieldsInvalid); } } - "profile" => { - if profile_text.replace(value).is_some() { - return Err(ProblemCode::UnsupportedRepresentation); + "formatProfile" => { + if format_profile_text.replace(value).is_some() { + return Err(ProblemCode::UnsupportedFormat); } } "bbox" => { @@ -1719,7 +1755,7 @@ fn prepare_list( return Err(ProblemCode::InvalidFilter); } } - "representation" => {} + "accessProfile" => {} _ if declared.contains(name.as_str()) => { if raw_filters.insert(name, value).is_some() { return Err(ProblemCode::InvalidFilter); @@ -1732,6 +1768,9 @@ fn prepare_list( .as_deref() .map(|value| parse_bbox(operation, value)) .transpose()?; + if matches!(operation.kind, OperationKind::Search { .. }) && bbox.is_none() { + return Err(ProblemCode::InvalidFilter); + } if raw_filters.is_empty() && bbox.is_none() && !operation.query.allow_unfiltered { return Err(ProblemCode::InvalidFilter); } @@ -1757,16 +1796,16 @@ fn prepare_list( let selected_fields = fields_from_text( resource, operation, - &access.representation, + &access.access_profile, fields_text.as_deref(), )?; - let response_format = select_profile( + let response_format = select_format_profile( resource, - &access.representation, + &access.access_profile, negotiated, - profile_text.as_deref(), + format_profile_text.as_deref(), )?; - Ok(PreparedList { + Ok(PreparedCollection { page_size, filters, selected_fields, @@ -1779,35 +1818,35 @@ fn prepare_list( fn prepare_single_request( resource: &CompiledResource, operation: &CompiledOperation, - representation: &CompiledRepresentation, + access_profile: &CompiledAccessProfile, headers: &HeaderMap, query: Option<&str>, ) -> Result<(ResponseFormat, Vec), ProblemCode> { - let negotiated = negotiate(headers, resource, representation)?; + let negotiated = negotiate(headers, resource, access_profile)?; let parameters = parse_query(query)?; if parameters .iter() - .any(|(name, _)| name != "fields" && name != "profile" && name != "representation") + .any(|(name, _)| name != "fields" && name != "formatProfile" && name != "accessProfile") { return Err(ProblemCode::ConsultationInvalidRequest); } let fields = one_parameter(¶meters, "fields")?; - let profile = one_parameter(¶meters, "profile") - .map_err(|_| ProblemCode::UnsupportedRepresentation)?; + let format_profile = + one_parameter(¶meters, "formatProfile").map_err(|_| ProblemCode::UnsupportedFormat)?; Ok(( - select_profile(resource, representation, negotiated, profile)?, - fields_from_text(resource, operation, representation, fields)?, + select_format_profile(resource, access_profile, negotiated, format_profile)?, + fields_from_text(resource, operation, access_profile, fields)?, )) } fn fields_from_text( resource: &CompiledResource, _operation: &CompiledOperation, - representation: &CompiledRepresentation, + access_profile: &CompiledAccessProfile, text: Option<&str>, ) -> Result, ProblemCode> { let Some(text) = text else { - return Ok(representation.selectable_properties.clone()); + return Ok(access_profile.selectable_properties.clone()); }; if text.is_empty() || text.bytes().any(|byte| byte.is_ascii_whitespace()) { return Err(ProblemCode::FieldsInvalid); @@ -1819,7 +1858,7 @@ fn fields_from_text( { return Err(ProblemCode::FieldsInvalid); } - let allowed = representation + let allowed = access_profile .selectable_properties .iter() .map(String::as_str) @@ -1837,7 +1876,7 @@ fn fields_from_text( }) { return Err(ProblemCode::FieldsInvalid); } - Ok(representation + Ok(access_profile .selectable_properties .iter() .filter(|field| requested.contains(&field.as_str())) @@ -1848,14 +1887,14 @@ fn fields_from_text( fn validate_selected_inventory( resource: &CompiledResource, operation: &CompiledOperation, - representation: &CompiledRepresentation, + access_profile: &CompiledAccessProfile, fields: &[String], ) -> Result<(), ProblemCode> { if fields.is_empty() { return Err(ProblemCode::CursorInvalid); } let text = fields.join(","); - let canonical = fields_from_text(resource, operation, representation, Some(&text)) + let canonical = fields_from_text(resource, operation, access_profile, Some(&text)) .map_err(|_| ProblemCode::CursorInvalid)?; if canonical != fields { return Err(ProblemCode::CursorInvalid); @@ -1868,6 +1907,10 @@ fn validate_filter_inventory( filters: &BTreeMap, bbox_present: bool, ) -> Result<(), ProblemCode> { + match (&operation.kind, &operation.query.spatial_bbox, bbox_present) { + (OperationKind::List, None, false) | (OperationKind::Search { .. }, Some(_), true) => {} + _ => return Err(ProblemCode::CursorInvalid), + } let declared = operation .query .filters @@ -1882,16 +1925,16 @@ fn validate_filter_inventory( Ok(()) } -fn select_profile( +fn select_format_profile( resource: &CompiledResource, - representation: &CompiledRepresentation, + access_profile: &CompiledAccessProfile, negotiated: ResponseFormat, requested: Option<&str>, ) -> Result { match negotiated { ResponseFormat::Json | ResponseFormat::JsonLd => { if requested.is_some() { - return Err(ProblemCode::UnsupportedRepresentation); + return Err(ProblemCode::UnsupportedFormat); } Ok(negotiated) } @@ -1899,12 +1942,12 @@ fn select_profile( let profile = match requested.unwrap_or("rfc7946") { "rfc7946" => GeoJsonProfile::Rfc7946, "jsonfg" => GeoJsonProfile::JsonFg, - _ => return Err(ProblemCode::UnsupportedRepresentation), + _ => return Err(ProblemCode::UnsupportedFormat), }; - if supports_geojson(resource, representation) { + if supports_geojson(resource, access_profile) { Ok(ResponseFormat::GeoJson(profile)) } else { - Err(ProblemCode::UnsupportedRepresentation) + Err(ProblemCode::UnsupportedFormat) } } } @@ -1912,18 +1955,18 @@ fn select_profile( fn response_format_from_cursor( resource: &CompiledResource, - representation: &CompiledRepresentation, + access_profile: &CompiledAccessProfile, payload: &CursorPayload, ) -> Result { match ( payload.response_format.as_str(), - payload.response_profile.as_deref(), + payload.format_profile.as_deref(), ) { ("json", None) => Ok(ResponseFormat::Json), ("json-ld", None) => Ok(ResponseFormat::JsonLd), - ("geojson", Some(profile)) => select_profile( + ("geojson", Some(profile)) => select_format_profile( resource, - representation, + access_profile, ResponseFormat::GeoJson(GeoJsonProfile::Rfc7946), Some(profile), ), @@ -1931,15 +1974,6 @@ fn response_format_from_cursor( } } -fn supports_geojson(resource: &CompiledResource, representation: &CompiledRepresentation) -> bool { - resource.primary_geometry.as_ref().is_some_and(|geometry| { - representation - .selectable_properties - .iter() - .any(|property| property == &geometry.name) - }) -} - fn parse_bbox(operation: &CompiledOperation, text: &str) -> Result { if text.is_empty() || text.len() > 256 || text.chars().any(char::is_control) { return Err(ProblemCode::InvalidFilter); @@ -2175,7 +2209,7 @@ enum RecordError { fn record_value( service: &RelayService, resource: &CompiledResource, - representation: &CompiledRepresentation, + access_profile: &CompiledAccessProfile, row: &ResultRow, selected: &[String], ) -> Result { @@ -2201,9 +2235,9 @@ fn record_value( { return Err(RecordError::InvalidCore); } - // Validate the complete selected representation before requester field + // Validate the complete selected access profile before requester field // minimization. Narrowing disclosure never lowers its processing floor. - let properties = representation + let properties = access_profile .selectable_properties .iter() .filter_map(|name| { @@ -2247,7 +2281,7 @@ fn record_value( transformed.insert(property.name.as_str(), value); } let selected_geometry = resource.primary_geometry.as_ref().filter(|geometry| { - representation + access_profile .selectable_properties .iter() .any(|property| property == &geometry.name) @@ -2280,8 +2314,8 @@ fn record_value( "recordIdentifier": record_identifier, "revisionIdentifier": revision, "lifecycleState": lifecycle, - "schemaReference": representation.schema_reference, - "semanticModelReference": representation.semantic_model_reference, + "schemaReference": access_profile.schema_reference, + "semanticModelReference": access_profile.semantic_model_reference, "authorityIdentifier": service.registry.authority_identifier, "recordedAt": recorded_at, "domainData": domain, @@ -2382,25 +2416,25 @@ fn record_meta( service: &RelayService, resource: &CompiledResource, operation: &CompiledOperation, - representation: &CompiledRepresentation, + access_profile: &CompiledAccessProfile, selected: &[String], source_revision: &SourceRevision, ) -> Value { let pattern = operation_pattern(operation.pattern); json!({ "operationIdentifier": operation.identifier, - "representation": representation.id, + "accessProfile": access_profile.id, "family": "consultation", "pattern": pattern, - "disclosureProfile": representation.disclosure_profile, + "disclosureProfile": access_profile.disclosure_profile, "contractRevision": service.registry.contract_revision, "sourceRevision": source_revision_value(source_revision), "selectedFields": selected, "links": { "self": operation_href(service, resource, operation), - "context": representation.context_reference, - "schema": representation.schema_reference, - "semanticModel": representation.semantic_model_reference, + "context": access_profile.context_reference, + "schema": access_profile.schema_reference, + "semanticModel": access_profile.semantic_model_reference, } }) } @@ -2490,7 +2524,7 @@ fn add_json_fg_members(document: &mut Value, resource: &CompiledResource) { }; object.insert( "conformsTo".into(), - json!([JSON_FG_CORE_CONFORMANCE, JSON_FG_TYPES_SCHEMAS_CONFORMANCE,]), + json!([JSON_FG_CORE_CONFORMANCE, JSON_FG_TYPES_CONFORMANCE,]), ); object.insert("featureType".into(), Value::String(resource.id.clone())); } @@ -2498,7 +2532,7 @@ fn add_json_fg_members(document: &mut Value, resource: &CompiledResource) { fn apply_json_ld( service: &RelayService, resource: &CompiledResource, - selected: &CompiledRepresentation, + selected: &CompiledAccessProfile, representation: ResponseFormat, document: &mut Value, ) { @@ -2691,23 +2725,21 @@ async fn terminal_problem( code.response(trace) } -fn cacheable(representation: &CompiledRepresentation, source: &SourceRevision) -> bool { - matches!(representation.access, CompiledAccess::Public) - && representation.processing_handling == Handling::Public +fn cacheable(access_profile: &CompiledAccessProfile, source: &SourceRevision) -> bool { + matches!(access_profile.access, CompiledAccess::Public) + && access_profile.processing_handling == Handling::Public && matches!(source, SourceRevision::Snapshot(_)) } fn negotiate( headers: &HeaderMap, resource: &CompiledResource, - representation: &CompiledRepresentation, + access_profile: &CompiledAccessProfile, ) -> Result { let Some(value) = headers.get(ACCEPT) else { return Ok(ResponseFormat::Json); }; - let value = value - .to_str() - .map_err(|_| ProblemCode::UnsupportedRepresentation)?; + let value = value.to_str().map_err(|_| ProblemCode::UnsupportedFormat)?; let mut json = false; let mut json_ld = false; let mut geojson = false; @@ -2727,12 +2759,12 @@ fn negotiate( } if json_ld { Ok(ResponseFormat::JsonLd) - } else if geojson && supports_geojson(resource, representation) { + } else if geojson && supports_geojson(resource, access_profile) { Ok(ResponseFormat::GeoJson(GeoJsonProfile::Rfc7946)) } else if json { Ok(ResponseFormat::Json) } else { - Err(ProblemCode::UnsupportedRepresentation) + Err(ProblemCode::UnsupportedFormat) } } @@ -2752,7 +2784,7 @@ fn next_cursor( service: &RelayService, operation: &CompiledOperation, access: &Access, - query: &PreparedList, + query: &PreparedCollection, last: &ResultRow, source_revision: &SourceRevision, ) -> Result { @@ -2823,13 +2855,13 @@ fn cursor_template( serde_json::to_vec(context.selected_fields).map_err(|_| ProblemCode::CursorInvalid)?; let order_json = serde_json::to_vec(&operation.query.order_by).map_err(|_| ProblemCode::CursorInvalid)?; - let transform_json = serde_json::to_vec(&access.representation.transform_inventory) + let transform_json = serde_json::to_vec(&access.access_profile.transform_inventory) .map_err(|_| ProblemCode::CursorInvalid)?; let authorization_material = access .principal .as_ref() .map(|principal| { - principal.authorization_material(&access.representation.access, &access.authorization) + principal.authorization_material(&access.access_profile.access, &access.authorization) }) .unwrap_or_else(|| b"anonymous".to_vec()); Ok(CursorPayload::new( @@ -2838,8 +2870,8 @@ fn cursor_template( context.source_revision.to_owned(), operation.identifier.clone(), CursorBindings { - representation: access.representation.id.clone(), - disclosure_profile: access.representation.disclosure_profile.clone(), + access_profile: access.access_profile.id.clone(), + disclosure_profile: access.access_profile.disclosure_profile.clone(), transforms_digest: key .binding_digest(b"transforms", &transform_json) .map_err(|_| ProblemCode::CursorInvalid)?, @@ -2867,7 +2899,7 @@ fn cursor_template( fn metadata_cursor_template( service: &RelayService, - visible: &[(&CompiledResource, Vec>)], + visible: &[(&CompiledResource, Vec>)], ) -> Result { let key = service .cursor_key @@ -2895,7 +2927,7 @@ fn metadata_cursor_template( format!("metadata:{}", service.registry.contract_revision), "registry.resources".to_owned(), CursorBindings { - representation: "metadata".to_owned(), + access_profile: "metadata".to_owned(), disclosure_profile: "metadata".to_owned(), transforms_digest: key .binding_digest(b"metadata-transforms", b"none") @@ -2919,7 +2951,7 @@ fn metadata_cursor_template( fn metadata_next_cursor( service: &RelayService, - visible: &[(&CompiledResource, Vec>)], + visible: &[(&CompiledResource, Vec>)], page_size: usize, last_resource_identifier: &str, ) -> Result { @@ -2985,7 +3017,7 @@ fn find_operation_by_id<'a>( async fn visible_resources<'a>( service: &'a RelayService, principal: Option<&Principal>, -) -> Result>)>, ProblemCode> { +) -> Result>)>, ProblemCode> { if service.registry.metadata_visibility.resources == Visibility::OperatorOnly { return Err(ProblemCode::ResourceNotFound); } @@ -3008,7 +3040,7 @@ async fn visible_operations<'a>( service: &'a RelayService, resource: &'a CompiledResource, principal: Option<&Principal>, -) -> Result>, ProblemCode> { +) -> Result>, ProblemCode> { match service.registry.metadata_visibility.resources { Visibility::OperatorOnly => Ok(Vec::new()), Visibility::Public => Ok(resource @@ -3016,12 +3048,12 @@ async fn visible_operations<'a>( .iter() .flat_map(|operation| { operation - .representations + .access_profiles .iter() - .filter(|representation| { - matches!(representation.access, CompiledAccess::Public) + .filter(|access_profile| { + matches!(access_profile.access, CompiledAccess::Public) }) - .map(move |representation| (operation, representation)) + .map(move |access_profile| (operation, access_profile)) }) .collect()), Visibility::OperationBound => { @@ -3035,13 +3067,13 @@ async fn visible_operations<'a>( .iter() .flat_map(|operation| { operation - .representations + .access_profiles .iter() - .filter_map(move |representation| { + .filter_map(move |access_profile| { authenticator - .authorize(&representation.access, Some(principal)) + .authorize(&access_profile.access, Some(principal)) .is_ok() - .then_some((operation, representation)) + .then_some((operation, access_profile)) }) }) .collect()) @@ -3057,20 +3089,20 @@ fn protected_artifact(artifact: &GeneratedArtifact) -> bool { artifact.visibility == Visibility::OperationBound } -type VisibleRepresentation<'a> = (&'a CompiledOperation, &'a CompiledRepresentation); +type VisibleAccessProfile<'a> = (&'a CompiledOperation, &'a CompiledAccessProfile); fn resource_document( service: &RelayService, resource: &CompiledResource, - operations: &[VisibleRepresentation<'_>], + operations: &[VisibleAccessProfile<'_>], ) -> Value { let enumeration = if operations .iter() .any(|(operation, _)| matches!(operation.kind, OperationKind::List)) { - if operations.iter().any(|(operation, representation)| { + if operations.iter().any(|(operation, access_profile)| { matches!(operation.kind, OperationKind::List) - && matches!(representation.access, CompiledAccess::Public) + && matches!(access_profile.access, CompiledAccess::Public) }) { "public" } else { @@ -3085,7 +3117,7 @@ fn resource_document( "description": resource.description, "semanticClass": resource.semantic_class, "enumerationPosture": enumeration, - "capabilities": operations.iter().map(|(operation, representation)| capability(service, resource, operation, representation)).collect::>(), + "capabilities": operations.iter().map(|(operation, access_profile)| capability(service, resource, operation, access_profile)).collect::>(), "links": { "self": absolute(&service.registry.base_uri, &format!("/v2/resources/{}", resource.id)), } @@ -3096,36 +3128,38 @@ fn capability( service: &RelayService, resource: &CompiledResource, operation: &CompiledOperation, - representation: &CompiledRepresentation, + access_profile: &CompiledAccessProfile, ) -> Value { let mut document = json!({ "family": "consultation", "pattern": operation_pattern(operation.pattern), "resourceIdentifier": resource.id, "operationIdentifier": operation.identifier, - "representation": representation.id, - "defaultRepresentation": operation.default_representation == representation.id, - "disclosureProfile": representation.disclosure_profile, - "schemaReference": representation.schema_reference, - "semanticModelReference": representation.semantic_model_reference, - "contextReference": representation.context_reference, + "accessProfile": access_profile.id, + "defaultAccessProfile": operation.default_access_profile == access_profile.id, + "disclosureProfile": access_profile.disclosure_profile, + "schemaReference": access_profile.schema_reference, + "semanticModelReference": access_profile.semantic_model_reference, + "contextReference": access_profile.context_reference, "href": match &operation.kind { OperationKind::List => format!("/v2/resources/{}/records", resource.id), OperationKind::Read => format!("/v2/resources/{}/records/{{recordIdentifier}}", resource.id), OperationKind::Lookup {name} => format!("/v2/resources/{}/lookups/{name}", resource.id), + OperationKind::Search {name} => format!("/v2/resources/{}/searches/{name}", resource.id), } }); let stem = format!( - "{}--representation-{}", + "{}--access-profile-{}", operation_artifact_stem(&resource.id, &operation.kind), - representation.id + access_profile.id ); let object = document .as_object_mut() .expect("capability document is an object"); object.insert( - "formats".into(), - Value::Array(response_format_documents(resource, representation)), + "wireFormats".into(), + serde_json::to_value(response_format_capabilities(resource, access_profile)) + .expect("compiled response format capabilities serialize"), ); if let Some(spatial) = &operation.query.spatial_bbox { object.insert( @@ -3144,7 +3178,7 @@ fn capability( object.insert( "classificationReference".into(), Value::String(sibling_artifact_reference( - &representation.schema_reference, + &access_profile.schema_reference, &format!("{stem}-classifications"), )), ); @@ -3153,7 +3187,7 @@ fn capability( object.insert( "processingReference".into(), Value::String(sibling_artifact_reference( - &representation.schema_reference, + &access_profile.schema_reference, &format!("{stem}-processing"), )), ); @@ -3161,46 +3195,6 @@ fn capability( document } -fn response_format_documents( - resource: &CompiledResource, - representation: &CompiledRepresentation, -) -> Vec { - let mut formats = vec![ - json!({"id": "json", "mediaType": "application/json", "profiles": []}), - json!({"id": "json-ld", "mediaType": "application/ld+json", "profiles": []}), - ]; - if supports_geojson(resource, representation) { - formats.push(json!({ - "id": "geojson", - "mediaType": "application/geo+json", - "profiles": [ - representation_profile_document(RepresentationProfile::Rfc7946), - representation_profile_document(RepresentationProfile::JsonFg), - ], - })); - } - formats -} - -fn representation_profile_document(profile: RepresentationProfile) -> Value { - match profile { - RepresentationProfile::Rfc7946 => json!({ - "id": "rfc7946", - "uri": RFC_7946_PROFILE_URI, - "crs": CRS84_URI, - }), - RepresentationProfile::JsonFg => json!({ - "id": "jsonfg", - "uri": JSON_FG_PROFILE_URI, - "crs": CRS84_URI, - "conformsTo": [ - JSON_FG_CORE_CONFORMANCE, - JSON_FG_TYPES_SCHEMAS_CONFORMANCE, - ], - }), - } -} - fn sibling_artifact_reference(reference: &str, artifact_identifier: &str) -> String { reference.rsplit_once("/v2/artifacts/").map_or_else( || format!("/v2/artifacts/{artifact_identifier}"), @@ -3213,6 +3207,7 @@ fn operation_artifact_stem(resource: &str, kind: &OperationKind) -> String { OperationKind::List => format!("{resource}--list"), OperationKind::Read => format!("{resource}--read"), OperationKind::Lookup { name } => format!("{resource}--lookup-{name}"), + OperationKind::Search { name } => format!("{resource}--search-{name}"), } } @@ -3237,6 +3232,9 @@ fn operation_href( OperationKind::Lookup { name } => { format!("/v2/resources/{}/lookups/{name}", resource.id) } + OperationKind::Search { name } => { + format!("/v2/resources/{}/searches/{name}", resource.id) + } }; absolute(&service.registry.base_uri, &path) } @@ -3393,30 +3391,35 @@ mod tests { } #[test] - fn representation_selection_scans_only_bounded_components() { + fn access_profile_selection_scans_only_bounded_components() { let padding = "x".repeat(20_000); - let query = format!("padding={padding}&representation=caseworker"); + let query = format!("padding={padding}&accessProfile=caseworker"); assert_eq!( - representation_parameter(Some(&query)).expect("selector extracts"), + access_profile_parameter(Some(&query)).expect("selector extracts"), Some("caseworker".into()) ); assert_eq!( - representation_parameter(Some("%72epresentation=limited")) + access_profile_parameter(Some("%61ccessProfile=limited")) .expect("encoded selector extracts"), Some("limited".into()) ); assert_eq!( - representation_parameter(Some("%=ignored&representation=limited")) + access_profile_parameter(Some("%=ignored&accessProfile=limited")) .expect("malformed unrelated name is deferred"), Some("limited".into()) ); assert_eq!( - representation_parameter(Some("representation=limited&representation=caseworker")), - Err(ProblemCode::RepresentationInvalid) + access_profile_parameter(Some("accessProfile=limited&accessProfile=caseworker")), + Err(ProblemCode::AccessProfileInvalid) + ); + assert_eq!( + access_profile_parameter(Some("accessProfile=limited=caseworker")), + Err(ProblemCode::AccessProfileInvalid) ); assert_eq!( - representation_parameter(Some("representation=limited=caseworker")), - Err(ProblemCode::RepresentationInvalid) + access_profile_parameter(Some("representation=legacy")) + .expect("legacy selector is not an alias"), + None ); } } diff --git a/crates/registry-relay-v2/src/artifacts.rs b/crates/registry-relay-v2/src/artifacts.rs index c0d878448..cc047ac6e 100644 --- a/crates/registry-relay-v2/src/artifacts.rs +++ b/crates/registry-relay-v2/src/artifacts.rs @@ -10,22 +10,19 @@ use sha2::{Digest, Sha256}; use thiserror::Error; use crate::contract::Visibility; +use crate::format_capabilities::{ + response_format_capabilities, supports_geojson, CRS84_URI, JSON_FG_CORE_CONFORMANCE, + JSON_FG_TYPES_CONFORMANCE, +}; use crate::model::{ CompiledAccess, CompiledOperation, CompiledRegistry, CompiledResource, ConsultationPattern, - OperationKind, RepresentationProfile, + OperationKind, }; use crate::semantics::{ - full_record_schema, full_record_shacl, json_ld_context, local_vocabulary, - representation_schema, representation_shacl, + access_profile_schema, access_profile_shacl, full_record_schema, full_record_shacl, + json_ld_context, local_vocabulary, }; -const CRS84_URI: &str = "http://www.opengis.net/def/crs/OGC/0/CRS84"; -const RFC7946_PROFILE_URI: &str = "http://www.opengis.net/def/profile/OGC/0/rfc7946"; -const JSON_FG_PROFILE_URI: &str = "http://www.opengis.net/def/profile/OGC/0/jsonfg"; -const JSON_FG_CORE_CONFORMANCE: &str = "http://www.opengis.net/spec/json-fg-1/1.0/conf/core"; -const JSON_FG_TYPES_CONFORMANCE: &str = - "http://www.opengis.net/spec/json-fg-1/1.0/conf/types-schemas"; - #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct ArtifactSet { @@ -51,8 +48,8 @@ pub struct GeneratedArtifact { /// the artifact behind this exact compiled operation's static access gate. pub operation_identifier: Option, /// Present with `operation_identifier` when an operation-bound artifact - /// belongs to one exact finite representation. - pub representation_identifier: Option, + /// belongs to one exact finite access profile. + pub access_profile_identifier: Option, pub sha256: String, pub content: Vec, } @@ -61,11 +58,11 @@ pub struct GeneratedArtifact { #[serde(rename_all = "camelCase")] pub struct OperationArtifactBindings { pub operation_identifier: String, - pub representation_identifier: String, + pub access_profile_identifier: String, pub vocabulary_path: String, pub context_path: String, - pub representation_schema_path: String, - pub representation_shacl_path: String, + pub access_profile_schema_path: String, + pub access_profile_shacl_path: String, pub classification_path: String, pub processing_path: String, } @@ -205,35 +202,35 @@ pub fn generate_artifacts(registry: &CompiledRegistry) -> Result Result Result>(), @@ -369,11 +366,11 @@ pub fn generate_artifacts(registry: &CompiledRegistry) -> Result Result String { OperationKind::List => "list".into(), OperationKind::Read => "read".into(), OperationKind::Lookup { name } => format!("lookup:{name}"), + OperationKind::Search { name } => format!("search:{name}"), } } @@ -446,16 +444,17 @@ fn operation_artifact_stem(resource: &str, kind: &OperationKind) -> String { OperationKind::List => format!("{resource}--list"), OperationKind::Read => format!("{resource}--read"), OperationKind::Lookup { name } => format!("{resource}--lookup-{name}"), + OperationKind::Search { name } => format!("{resource}--search-{name}"), } } -fn representation_artifact_stem( +fn access_profile_artifact_stem( resource: &str, kind: &OperationKind, - representation: &str, + access_profile: &str, ) -> String { format!( - "{}--representation-{representation}", + "{}--access-profile-{access_profile}", operation_artifact_stem(resource, kind) ) } @@ -484,14 +483,14 @@ fn push_json( } #[allow(clippy::too_many_arguments)] -fn push_representation_json( +fn push_access_profile_json( artifacts: &mut Vec, id: &str, path: &str, media_type: &str, visibility: Visibility, operation_identifier: &str, - representation_identifier: &str, + access_profile_identifier: &str, value: &Value, ) -> Result<(), ArtifactError> { let bound = visibility == Visibility::OperationBound; @@ -506,20 +505,20 @@ fn push_representation_json( )?; artifacts .last_mut() - .expect("a representation artifact was appended") - .representation_identifier = bound.then(|| representation_identifier.to_owned()); + .expect("an access-profile artifact was appended") + .access_profile_identifier = bound.then(|| access_profile_identifier.to_owned()); Ok(()) } #[allow(clippy::too_many_arguments)] -fn push_representation_text( +fn push_access_profile_text( artifacts: &mut Vec, id: &str, path: &str, media_type: &str, visibility: Visibility, operation_identifier: &str, - representation_identifier: &str, + access_profile_identifier: &str, content: Vec, ) { let bound = visibility == Visibility::OperationBound; @@ -534,8 +533,8 @@ fn push_representation_text( ); artifacts .last_mut() - .expect("a representation artifact was appended") - .representation_identifier = bound.then(|| representation_identifier.to_owned()); + .expect("an access-profile artifact was appended") + .access_profile_identifier = bound.then(|| access_profile_identifier.to_owned()); } #[allow(clippy::too_many_arguments)] @@ -554,7 +553,7 @@ fn push_text( media_type: media_type.into(), visibility, operation_identifier, - representation_identifier: None, + access_profile_identifier: None, sha256: format!("sha256:{}", hex::encode(Sha256::digest(&content))), content, }); @@ -614,14 +613,14 @@ fn openapi(registry: &CompiledRegistry, public_only: bool) -> Value { } for resource in ®istry.resources { for operation in &resource.operations { - let visible_representations = operation - .representations + let visible_access_profiles = operation + .access_profiles .iter() - .filter(|representation| { - !public_only || matches!(&representation.access, CompiledAccess::Public) + .filter(|access_profile| { + !public_only || matches!(&access_profile.access, CompiledAccess::Public) }) .collect::>(); - if visible_representations.is_empty() { + if visible_access_profiles.is_empty() { continue; } let (method, path) = match &operation.kind { @@ -634,58 +633,62 @@ fn openapi(registry: &CompiledRegistry, public_only: bool) -> Value { "post", format!("/v2/resources/{}/lookups/{name}", resource.id), ), + OperationKind::Search { name } => ( + "get", + format!("/v2/resources/{}/searches/{name}", resource.id), + ), }; - let has_public = visible_representations + let has_public = visible_access_profiles .iter() - .any(|representation| matches!(&representation.access, CompiledAccess::Public)); - let has_protected = visible_representations.iter().any(|representation| { - matches!(&representation.access, CompiledAccess::Protected { .. }) + .any(|access_profile| matches!(&access_profile.access, CompiledAccess::Public)); + let has_protected = visible_access_profiles.iter().any(|access_profile| { + matches!(&access_profile.access, CompiledAccess::Protected { .. }) }); let security = match (has_public, has_protected) { (true, true) => json!([{}, {"bearerAuth": []}]), (true, false) => json!([]), (false, true) => json!([{"bearerAuth": []}]), - (false, false) => unreachable!("a visible representation exists"), + (false, false) => unreachable!("a visible access profile exists"), }; - let visible_identifiers = visible_representations + let visible_identifiers = visible_access_profiles .iter() - .map(|representation| representation.id.clone()) + .map(|access_profile| access_profile.id.clone()) .collect::>(); let visible_default = visible_identifiers - .contains(&operation.default_representation) - .then(|| operation.default_representation.clone()); - let mut representation_schema = json!({ + .contains(&operation.default_access_profile) + .then(|| operation.default_access_profile.clone()); + let mut access_profile_schema = json!({ "type": "string", "enum": visible_identifiers, }); if let Some(default) = &visible_default { - representation_schema + access_profile_schema .as_object_mut() - .expect("representation schema object") + .expect("access-profile schema object") .insert("default".into(), json!(default)); } let mut parameters = vec![ json!({ - "name": "representation", + "name": "accessProfile", "in": "query", "required": false, - "schema": representation_schema, - "description": "One finite compiled representation. Absence selects the declared default." + "schema": access_profile_schema, + "description": "One finite compiled access profile. Absence selects the declared default." }), json!({ "name": "fields", "in": "query", "required": false, "schema": {"type": "string", "minLength": 1}, - "description": "Duplicate-free comma-separated subset of the selected representation" + "description": "Duplicate-free comma-separated subset of the selected access profile" }), ]; - let has_geojson = visible_representations + let has_geojson = visible_access_profiles .iter() - .any(|representation| supports_geojson(resource, representation)); + .any(|access_profile| supports_geojson(resource, access_profile)); if has_geojson { parameters.push(json!({ - "name": "profile", + "name": "formatProfile", "in": "query", "required": false, "schema": {"type": "string", "enum": ["rfc7946", "jsonfg"], "default": "rfc7946"}, @@ -693,7 +696,7 @@ fn openapi(registry: &CompiledRegistry, public_only: bool) -> Value { })); } match &operation.kind { - OperationKind::List => { + OperationKind::List | OperationKind::Search { .. } => { let pagination = operation .query .pagination @@ -714,7 +717,7 @@ fn openapi(registry: &CompiledRegistry, public_only: bool) -> Value { parameters.push(json!({ "name": "bbox", "in": "query", - "required": false, + "required": true, "style": "form", "explode": false, "schema": { @@ -743,7 +746,7 @@ fn openapi(registry: &CompiledRegistry, public_only: bool) -> Value { registry, operation, resource, - &visible_representations, + &visible_access_profiles, ) }); if has_geojson { @@ -764,17 +767,17 @@ fn openapi(registry: &CompiledRegistry, public_only: bool) -> Value { "operationId": operation.identifier, "x-registry-family": "consultation", "x-registry-pattern": consultation_pattern(operation.pattern), - "x-registry-representations": visible_representations.iter().map(|representation| json!({ - "identifier": representation.id, - "default": operation.default_representation == representation.id, - "disclosureProfile": representation.disclosure_profile, - "processingHandling": representation.processing_handling, - "disclosureHandling": representation.disclosure_handling, - "transformIdentifiers": representation.transform_inventory, - "schemaReference": representation.schema_reference, - "semanticModelReference": representation.semantic_model_reference, - "contextReference": representation.context_reference, - "formats": response_format_documents(resource, representation), + "x-registry-access-profiles": visible_access_profiles.iter().map(|access_profile| json!({ + "identifier": access_profile.id, + "default": operation.default_access_profile == access_profile.id, + "disclosureProfile": access_profile.disclosure_profile, + "processingHandling": access_profile.processing_handling, + "disclosureHandling": access_profile.disclosure_handling, + "transformIdentifiers": access_profile.transform_inventory, + "schemaReference": access_profile.schema_reference, + "semanticModelReference": access_profile.semantic_model_reference, + "contextReference": access_profile.context_reference, + "wireFormats": response_format_capabilities(resource, access_profile), })).collect::>(), "security": security, "parameters": parameters, @@ -783,12 +786,12 @@ fn openapi(registry: &CompiledRegistry, public_only: bool) -> Value { "default": {"$ref": "#/components/responses/Problem"} } }); - let required_scopes = visible_representations + let required_scopes = visible_access_profiles .iter() - .filter_map(|representation| match &representation.access { + .filter_map(|access_profile| match &access_profile.access { CompiledAccess::Public => None, CompiledAccess::Protected { scope, .. } => Some(json!({ - "representation": representation.id, + "accessProfile": access_profile.id, "scope": scope, })), }) @@ -889,20 +892,20 @@ fn openapi(registry: &CompiledRegistry, public_only: bool) -> Value { fn operation_response_schema( operation: &crate::model::CompiledOperation, - representations: &[&crate::model::CompiledRepresentation], + access_profiles: &[&crate::model::CompiledAccessProfile], ) -> Value { let meta = json!({"type": "object"}); - let record = if representations.len() == 1 { - json!({"$ref": representations[0].schema_reference}) + let record = if access_profiles.len() == 1 { + json!({"$ref": access_profiles[0].schema_reference}) } else { json!({ - "oneOf": representations.iter().map(|representation| { - json!({"$ref": representation.schema_reference}) + "oneOf": access_profiles.iter().map(|access_profile| { + json!({"$ref": access_profile.schema_reference}) }).collect::>() }) }; match &operation.kind { - OperationKind::List => json!({ + OperationKind::List | OperationKind::Search { .. } => json!({ "type": "object", "additionalProperties": false, "required": ["items", "pageInfo", "meta"], "properties": { @@ -930,9 +933,9 @@ fn operation_response_content( registry: &CompiledRegistry, operation: &CompiledOperation, resource: &CompiledResource, - representations: &[&crate::model::CompiledRepresentation], + access_profiles: &[&crate::model::CompiledAccessProfile], ) -> Value { - let ordinary = operation_response_schema(operation, representations); + let ordinary = operation_response_schema(operation, access_profiles); let mut content = Map::from_iter([ ( "application/json".into(), @@ -940,11 +943,11 @@ fn operation_response_content( ), ("application/ld+json".into(), json!({"schema": ordinary})), ]); - let spatial = representations + let spatial = access_profiles .iter() - .filter(|representation| supports_geojson(resource, representation)) - .map(|representation| { - geojson_response_schema(registry, operation, representation, resource, false) + .filter(|access_profile| supports_geojson(resource, access_profile)) + .map(|access_profile| { + geojson_response_schema(registry, operation, access_profile, resource, false) }) .collect::>(); if !spatial.is_empty() { @@ -961,12 +964,12 @@ fn operation_response_content( fn geojson_response_schema( registry: &CompiledRegistry, operation: &CompiledOperation, - representation: &crate::model::CompiledRepresentation, + access_profile: &crate::model::CompiledAccessProfile, resource: &CompiledResource, include_identity: bool, ) -> Value { let mut schema = match &operation.kind { - OperationKind::List => json!({ + OperationKind::List | OperationKind::Search { .. } => json!({ "type": "object", "additionalProperties": false, "required": ["type", "features", "pageInfo", "meta"], @@ -974,7 +977,7 @@ fn geojson_response_schema( "type": {"type": "string", "enum": ["FeatureCollection"]}, "features": { "type": "array", - "items": geojson_feature_schema(registry, representation, resource, false) + "items": geojson_feature_schema(registry, access_profile, resource, false) }, "pageInfo": { "type": "object", @@ -992,7 +995,7 @@ fn geojson_response_schema( } }), OperationKind::Read | OperationKind::Lookup { .. } => { - geojson_feature_schema(registry, representation, resource, true) + geojson_feature_schema(registry, access_profile, resource, true) } }; if include_identity { @@ -1008,11 +1011,11 @@ fn geojson_response_schema( .expect("GeoJSON schema object") .insert( "$id".into(), - json!(representation + json!(access_profile .schema_reference .strip_suffix("-schema") .map(|base| format!("{base}-geojson-schema")) - .unwrap_or_else(|| format!("{}-geojson", representation.schema_reference))), + .unwrap_or_else(|| format!("{}-geojson", access_profile.schema_reference))), ); } schema @@ -1020,7 +1023,7 @@ fn geojson_response_schema( fn geojson_feature_schema( registry: &CompiledRegistry, - representation: &crate::model::CompiledRepresentation, + access_profile: &crate::model::CompiledAccessProfile, resource: &CompiledResource, require_meta: bool, ) -> Value { @@ -1039,7 +1042,7 @@ fn geojson_feature_schema( "geometry": { "oneOf": [point_geometry_schema(), {"type": "null"}] }, - "properties": geojson_record_properties_schema(registry, representation, resource) + "properties": geojson_record_properties_schema(registry, access_profile, resource) }); if require_meta { let properties = properties @@ -1075,25 +1078,25 @@ fn geojson_feature_schema( fn geojson_record_properties_schema( registry: &CompiledRegistry, - representation: &crate::model::CompiledRepresentation, + access_profile: &crate::model::CompiledAccessProfile, resource: &CompiledResource, ) -> Value { let geometry_name = resource .primary_geometry .as_ref() .map(|geometry| geometry.name.as_str()); - let selected = representation + let selected = access_profile .selectable_properties .iter() .filter(|property| Some(property.as_str()) != geometry_name) .cloned() .collect::>(); - let mut schema = representation_schema( + let mut schema = access_profile_schema( registry, resource, &selected, - &representation.schema_reference, - &representation.semantic_model_reference, + &access_profile.schema_reference, + &access_profile.semantic_model_reference, ); let object = schema .as_object_mut() @@ -1145,55 +1148,6 @@ fn consultation_pattern(pattern: ConsultationPattern) -> &'static str { } } -fn supports_geojson( - resource: &CompiledResource, - representation: &crate::model::CompiledRepresentation, -) -> bool { - resource.primary_geometry.as_ref().is_some_and(|geometry| { - representation - .selectable_properties - .iter() - .any(|property| property == &geometry.name) - }) -} - -fn response_format_documents( - resource: &CompiledResource, - representation: &crate::model::CompiledRepresentation, -) -> Vec { - let mut formats = vec![ - json!({"id": "json", "mediaType": "application/json", "profiles": []}), - json!({"id": "json-ld", "mediaType": "application/ld+json", "profiles": []}), - ]; - if supports_geojson(resource, representation) { - formats.push(json!({ - "id": "geojson", - "mediaType": "application/geo+json", - "profiles": [ - representation_profile(RepresentationProfile::Rfc7946), - representation_profile(RepresentationProfile::JsonFg), - ], - })); - } - formats -} - -fn representation_profile(profile: RepresentationProfile) -> Value { - match profile { - RepresentationProfile::Rfc7946 => json!({ - "id": "rfc7946", - "uri": RFC7946_PROFILE_URI, - "crs": CRS84_URI, - }), - RepresentationProfile::JsonFg => json!({ - "id": "jsonfg", - "uri": JSON_FG_PROFILE_URI, - "crs": CRS84_URI, - "conformsTo": [JSON_FG_CORE_CONFORMANCE, JSON_FG_TYPES_CONFORMANCE], - }), - } -} - fn openapi_type(data_type: crate::contract::DataType) -> Value { use crate::contract::DataType; match data_type { @@ -1219,7 +1173,7 @@ fn openapi_type(data_type: crate::contract::DataType) -> Value { enum CapabilityProjection<'a> { Public, Full, - Representation(&'a str, &'a str), + AccessProfile(&'a str, &'a str), } fn capability_inventory( @@ -1231,18 +1185,18 @@ fn capability_inventory( .iter() .flat_map(|resource| { resource.operations.iter().flat_map(move |operation| { - operation.representations.iter().filter_map(move |representation| { + operation.access_profiles.iter().filter_map(move |access_profile| { let include = match projection { CapabilityProjection::Public => { - matches!(&representation.access, CompiledAccess::Public) + matches!(&access_profile.access, CompiledAccess::Public) } CapabilityProjection::Full => true, - CapabilityProjection::Representation( + CapabilityProjection::AccessProfile( operation_identifier, - representation_identifier, + access_profile_identifier, ) => { operation.identifier == operation_identifier - && representation.id == representation_identifier + && access_profile.id == access_profile_identifier } }; if !include { @@ -1252,19 +1206,25 @@ fn capability_inventory( OperationKind::List => "list", OperationKind::Read => "retrieve", OperationKind::Lookup { .. } => "search", + OperationKind::Search { .. } => "search", }; Some(json!({ "resource": resource.id, "operationIdentifier": operation.identifier, - "representationIdentifier": representation.id, - "defaultRepresentation": operation.default_representation == representation.id, + "accessProfileIdentifier": access_profile.id, + "defaultAccessProfile": operation.default_access_profile == access_profile.id, "family": "consultation", "pattern": pattern, - "profile": if matches!(&operation.kind, OperationKind::Lookup { .. }) { Value::String("exact".into()) } else { Value::Null }, - "schemaReference": representation.schema_reference, - "semanticModelReference": representation.semantic_model_reference, - "contextReference": representation.context_reference, - "formats": response_format_documents(resource, representation), + "queryKind": match &operation.kind { + OperationKind::List => "list", + OperationKind::Read => "record-identifier", + OperationKind::Lookup { .. } => "exact-lookup", + OperationKind::Search { .. } => "point-bbox", + }, + "schemaReference": access_profile.schema_reference, + "semanticModelReference": access_profile.semantic_model_reference, + "contextReference": access_profile.context_reference, + "wireFormats": response_format_capabilities(resource, access_profile), "spatialQuery": operation.query.spatial_bbox.as_ref().map(|spatial| json!({ "bbox": { "crs": CRS84_URI, @@ -1313,7 +1273,7 @@ fn audit_event_schema() -> Value { "accessRuleRevision": {"type": "string", "minLength": 1}, "purpose": {"type": "string", "minLength": 1}, "rowBoundaryKind": {"enum": ["none", "principal", "verified-claim", "unknown"]}, - "representation": {"type": "string", "minLength": 1}, + "accessProfile": {"type": "string", "minLength": 1}, "disclosureProfile": {"type": "string", "minLength": 1}, "processingDescriptionIdentifiers": {"type": "array", "items": {"type": "string", "minLength": 1}, "uniqueItems": true}, "selectedProperties": {"type": "array", "items": {"type": "string", "minLength": 1}, "uniqueItems": true}, @@ -1346,6 +1306,7 @@ mod tests { use super::*; use crate::compiler::{compile_contract_with_governed_files, tests as compiler_tests}; use crate::contract::RegistryContract; + use crate::format_capabilities::JSON_FG_PROFILE_URI; use crate::model::{CompileProfile, CompiledRegistry}; #[test] @@ -1375,10 +1336,10 @@ mod tests { "artifacts/record.full.schema.json", "artifacts/record.full.shacl.ttl", "artifacts/record.full.vocabulary.jsonld", - "artifacts/record--read--representation-public.schema.json", - "artifacts/record--read--representation-public.shacl.ttl", - "artifacts/record--read--representation-public.context.jsonld", - "artifacts/record--read--representation-public.vocabulary.jsonld", + "artifacts/record--read--access-profile-public.schema.json", + "artifacts/record--read--access-profile-public.shacl.ttl", + "artifacts/record--read--access-profile-public.context.jsonld", + "artifacts/record--read--access-profile-public.vocabulary.jsonld", ] { assert!(paths.contains(required), "missing {required}"); } @@ -1471,7 +1432,7 @@ mod tests { &compiler_tests::governed_files(), ) .expect("contract compiles"); - registry.resources[0].operations[0].representations[0].access = CompiledAccess::Protected { + registry.resources[0].operations[0].access_profiles[0].access = CompiledAccess::Protected { scope: "records:read".into(), purpose: None, row_binding: None, @@ -1481,12 +1442,12 @@ mod tests { registry.metadata_visibility.processing = Visibility::OperationBound; let generated = generate_artifacts(®istry).expect("artifacts generate"); for id in [ - "record--read--representation-public-vocabulary", - "record--read--representation-public-context", - "record--read--representation-public-schema", - "record--read--representation-public-shacl", - "record--read--representation-public-classifications", - "record--read--representation-public-processing", + "record--read--access-profile-public-vocabulary", + "record--read--access-profile-public-context", + "record--read--access-profile-public-schema", + "record--read--access-profile-public-shacl", + "record--read--access-profile-public-classifications", + "record--read--access-profile-public-processing", ] { let artifact = generated .artifacts @@ -1499,7 +1460,7 @@ mod tests { Some("record.read") ); assert_eq!( - artifact.representation_identifier.as_deref(), + artifact.access_profile_identifier.as_deref(), Some("public") ); } @@ -1513,15 +1474,15 @@ mod tests { .artifacts .iter() .find(|artifact| { - artifact.id == "record--read--representation-public-vocabulary" + artifact.id == "record--read--access-profile-public-vocabulary" }) .expect("semantic projection") .visibility, Visibility::OperationBound ); for id in [ - "record--read--representation-public-classifications", - "record--read--representation-public-processing", + "record--read--access-profile-public-classifications", + "record--read--access-profile-public-processing", ] { assert_eq!( generated @@ -1545,7 +1506,7 @@ mod tests { ); let geojson_schema = generated - .get("artifacts/record--list--representation-public.geojson.schema.json") + .get("artifacts/record--search-within-bbox--access-profile-public.geojson.schema.json") .expect("GeoJSON wrapper schema"); let schema: Value = serde_json::from_slice(&geojson_schema.content).expect("schema JSON"); assert_eq!(schema["properties"]["type"]["enum"][0], "FeatureCollection"); @@ -1568,7 +1529,7 @@ mod tests { .expect("generated GeoJSON schema compiles"); let resource = ®istry.resources[0]; let operation = &resource.operations[0]; - let representation = &operation.representations[0]; + let access_profile = &operation.access_profiles[0]; let lifecycle = registry.codelists[0].values[0].clone(); let point = json!({"type": "Point", "coordinates": [100.0, 13.0]}); let record = json!({ @@ -1576,8 +1537,8 @@ mod tests { "recordIdentifier": "record-1", "revisionIdentifier": "revision-1", "lifecycleState": lifecycle, - "schemaReference": representation.schema_reference, - "semanticModelReference": representation.semantic_model_reference, + "schemaReference": access_profile.schema_reference, + "semanticModelReference": access_profile.semantic_model_reference, "authorityIdentifier": registry.authority_identifier, "recordedAt": "2026-08-10T00:00:00Z", "domainData": {"name": "Example"} @@ -1628,7 +1589,7 @@ mod tests { .expect("OpenAPI JSON"); serde_json::from_value::(openapi.clone()) .expect("spatial OpenAPI conforms to the maintained OpenAPI model"); - let operation = &openapi["paths"]["/v2/resources/record/records"]["get"]; + let operation = &openapi["paths"]["/v2/resources/record/searches/within-bbox"]["get"]; assert_eq!(operation["x-registry-pattern"], "search"); assert!(operation["responses"]["200"]["content"] .get("application/geo+json") @@ -1641,11 +1602,35 @@ mod tests { .expect("bbox parameter"); assert_eq!(bbox["schema"]["minItems"], 4); assert_eq!(bbox["explode"], false); + assert_eq!(bbox["required"], true); + assert!(operation["parameters"] + .as_array() + .expect("parameters") + .iter() + .any(|parameter| parameter["name"] == "accessProfile")); + assert!(operation["parameters"] + .as_array() + .expect("parameters") + .iter() + .any(|parameter| parameter["name"] == "formatProfile")); + let expected_formats = + serde_json::to_value(response_format_capabilities(resource, access_profile)) + .expect("format capabilities serialize"); + assert_eq!( + operation["x-registry-access-profiles"][0]["wireFormats"], + expected_formats + ); let capabilities = generated .get("artifacts/capabilities.json") .expect("public capabilities"); let encoded = String::from_utf8(capabilities.content.clone()).expect("UTF-8 capability"); + let capability_document: Value = + serde_json::from_slice(&capabilities.content).expect("capability JSON"); + assert_eq!( + capability_document["capabilities"][0]["wireFormats"], + expected_formats + ); assert!(encoded.contains("exact-point-intersection")); assert!(encoded.contains(JSON_FG_PROFILE_URI)); assert!(!encoded.contains("longitude_col")); @@ -1683,14 +1668,16 @@ mod tests { assert!(!public_openapi.contains("exact-point-intersection")); assert!(!public_capabilities.contains("application/geo+json")); assert!(!public_capabilities.contains("exact-point-intersection")); + assert!(!public_openapi.contains("/searches/within-bbox")); + assert!(!public_capabilities.contains("record.search.within-bbox")); let operation_capability = generated - .get("artifacts/record--list--representation-public.capability.json") + .get("artifacts/record--search-within-bbox--access-profile-public.capability.json") .expect("operation-bound capability"); assert_eq!(operation_capability.visibility, Visibility::OperationBound); assert_eq!( operation_capability.operation_identifier.as_deref(), - Some("record.list") + Some("record.search.within-bbox") ); let encoded = String::from_utf8(operation_capability.content.clone()) .expect("UTF-8 operation capability"); @@ -1709,7 +1696,7 @@ mod tests { &governed_files, ) .expect("contract compiles"); - registry.resources[0].operations[0].representations[0].access = access; + registry.resources[0].operations[0].access_profiles[0].access = access; registry } } diff --git a/crates/registry-relay-v2/src/audit.rs b/crates/registry-relay-v2/src/audit.rs index 8d6cdfdbb..fd0768a73 100644 --- a/crates/registry-relay-v2/src/audit.rs +++ b/crates/registry-relay-v2/src/audit.rs @@ -108,7 +108,7 @@ pub struct AuditContext { pub access_rule_revision: Option, pub purpose: Option, pub row_boundary_kind: RowBoundaryKind, - pub representation: Option, + pub access_profile: Option, pub disclosure_profile: Option, pub processing_description_identifiers: Vec, pub selected_properties: Vec, @@ -180,7 +180,7 @@ struct AuditEvent { purpose: Option, row_boundary_kind: RowBoundaryKind, #[serde(skip_serializing_if = "Option::is_none")] - representation: Option, + access_profile: Option, #[serde(skip_serializing_if = "Option::is_none")] disclosure_profile: Option, processing_description_identifiers: Vec, @@ -215,7 +215,7 @@ impl AuditEvent { access_rule_revision: context.access_rule_revision.clone(), purpose: context.purpose.clone(), row_boundary_kind: context.row_boundary_kind, - representation: context.representation.clone(), + access_profile: context.access_profile.clone(), disclosure_profile: context.disclosure_profile.clone(), processing_description_identifiers: context.processing_description_identifiers.clone(), selected_properties: context.selected_properties.clone(), @@ -244,3 +244,42 @@ fn source_revision(revision: &SourceRevision) -> Value { }), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn audit_schema_names_the_governed_access_profile() { + let context = AuditContext { + operation_id: "operation-1".into(), + trace_id: TraceId::parse("0123456789abcdef0123456789abcdef").expect("trace"), + registry_identifier: "registry".into(), + resource_identifier: Some("resource".into()), + operation_identifier: Some("resource.search.within-bbox".into()), + access_rule_revision: Some("sha256:access".into()), + purpose: None, + row_boundary_kind: RowBoundaryKind::None, + access_profile: Some("public-premises".into()), + disclosure_profile: Some("public-premises".into()), + processing_description_identifiers: Vec::new(), + selected_properties: vec!["premisesIdentifier".into()], + processing_handling: Some("public".into()), + disclosure_handling: Some("public".into()), + transform_identifiers: Vec::new(), + contract_revision: "sha256:contract".into(), + source_revision: Some(SourceRevision::LiveUnversioned), + principal_kind: PrincipalKind::Anonymous, + }; + let value = serde_json::to_value(AuditEvent::from_context( + &context, + AuditPhase::Attempt, + None, + )) + .expect("audit serializes"); + + assert_eq!(value["schema"], AUDIT_SCHEMA); + assert_eq!(value["accessProfile"], "public-premises"); + assert!(value.get("representation").is_none()); + } +} diff --git a/crates/registry-relay-v2/src/compiler.rs b/crates/registry-relay-v2/src/compiler.rs index cb4084219..d213623ee 100644 --- a/crates/registry-relay-v2/src/compiler.rs +++ b/crates/registry-relay-v2/src/compiler.rs @@ -10,29 +10,36 @@ use sha2::{Digest, Sha256}; use url::Url; use crate::contract::{ - AccessRule, AuthorityRowBinding, ClassificationPartial, DataType, DateInputType, DatePrecision, - Handling, IdentificationMethod, RegistryContract, RepresentationDefinition, ReviewStatus, - SourceProfile, TransformDefinition, + AccessProfileDefinition, AccessRule, AuthorityRowBinding, ClassificationPartial, DataType, + DateInputType, DatePrecision, Handling, IdentificationMethod, RegistryContract, ReviewStatus, + SearchQueryDefinition, SourceProfile, TransformDefinition, }; use crate::model::{ CapabilityFamily, ColumnAccount, ColumnUse, CompileProfile, CompileReport, CompiledAccess, - CompiledClassificationReview, CompiledCodelist, CompiledDisclosureProfile, CompiledFilter, - CompiledGeneratedIdentificationBinding, CompiledGovernedFile, CompiledMetadataVisibility, - CompiledOperation, CompiledPagination, CompiledPrimaryGeometry, CompiledProperty, - CompiledPurpose, CompiledRecordContext, CompiledRegistry, CompiledRepresentation, - CompiledResource, CompiledRowBinding, CompiledSelector, CompiledSource, + CompiledAccessProfile, CompiledClassificationReview, CompiledCodelist, + CompiledDisclosureProfile, CompiledFilter, CompiledGeneratedIdentificationBinding, + CompiledGovernedFile, CompiledMetadataVisibility, CompiledOperation, CompiledPagination, + CompiledPrimaryGeometry, CompiledProperty, CompiledPurpose, CompiledRecordContext, + CompiledRegistry, CompiledResource, CompiledRowBinding, CompiledSelector, CompiledSource, CompiledSpatialBboxQuery, CompiledTransform, ConsultationPattern, Diagnostic, DiagnosticSeverity, EffectiveClassification, ObservedSourceSchema, OperationKind, QueryPlan, RowAuthoritySource, StarterColumn, StarterContract, }; const API_VERSION: &str = "relay.registrystack.org/v2alpha1"; -const RESERVED_PARAMETERS: [&str; 4] = ["pageSize", "cursor", "fields", "representation"]; +const RESERVED_PARAMETERS: [&str; 5] = [ + "pageSize", + "cursor", + "fields", + "accessProfile", + "formatProfile", +]; const MAXIMUM_RESOURCES: usize = 128; const MAXIMUM_PROPERTIES_PER_RESOURCE: usize = 128; const MAXIMUM_DISCLOSURE_PROFILES_PER_RESOURCE: usize = 64; -const MAXIMUM_REPRESENTATIONS_PER_OPERATION: usize = 16; -const MAXIMUM_REPRESENTATION_EXECUTORS_PER_REGISTRY: usize = 128; +const MAXIMUM_ACCESS_PROFILES_PER_OPERATION: usize = 16; +const MAXIMUM_ACCESS_PROFILE_EXECUTORS_PER_REGISTRY: usize = 128; +const MAXIMUM_SEARCHES_PER_RESOURCE: usize = 32; const MAXIMUM_LIST_FILTERS: usize = 32; const MAXIMUM_LIST_ORDER_KEYS: usize = 32; const MAXIMUM_LIST_PAGE_SIZE: u32 = 1_000; @@ -97,16 +104,16 @@ pub fn compile_contract( let mut compiler = Compiler::new(contract, observed, profile); compiler.validate_top_level(); let resources = compiler.compile_resources(); - let representation_executors = resources + let access_profile_executors = resources .iter() .flat_map(|resource| &resource.operations) - .map(|operation| operation.representations.len()) + .map(|operation| operation.access_profiles.len()) .sum::(); - if representation_executors > MAXIMUM_REPRESENTATION_EXECUTORS_PER_REGISTRY { + if access_profile_executors > MAXIMUM_ACCESS_PROFILE_EXECUTORS_PER_REGISTRY { compiler.error( - "representation.registry_bound_exceeded", + "access_profile.registry_bound_exceeded", "resources", - "the compiled representation count exceeds the Registry runtime ceiling", + "the compiled access profile count exceeds the Registry runtime ceiling", ); } compiler.validate_observed_source_closure(); @@ -1199,8 +1206,8 @@ impl<'a> Compiler<'a> { &root, "read", OperationKind::Read, - &read.default_representation, - &read.representations, + &read.default_access_profile, + &read.access_profiles, ) { operations.push(operation); } @@ -1313,8 +1320,8 @@ impl<'a> Compiler<'a> { OperationKind::Lookup { name: lookup.id.clone(), }, - &lookup.default_representation, - &lookup.representations, + &lookup.default_access_profile, + &lookup.access_profiles, ) { operation.identifier = format!("{}.lookup.{}", resource.id, lookup.id); operation.query.selectors = selectors; @@ -1323,6 +1330,50 @@ impl<'a> Compiler<'a> { operations.push(operation); } } + if resource.operations.searches.len() > MAXIMUM_SEARCHES_PER_RESOURCE { + self.error( + "operation.search_bound_exceeded", + &format!("{root}.operations.searches"), + "the named search count exceeds the per-resource product ceiling", + ); + } + let mut search_ids = HashSet::new(); + for (search_index, search) in resource.operations.searches.iter().enumerate() { + let location = format!("{root}.operations.searches[{search_index}]"); + if !search_ids.insert(search.id.as_str()) { + self.error( + "operation.search_id_duplicate", + &format!("{location}.id"), + "search identifiers must be unique within a resource", + ); + } + if !valid_kebab_identifier(&search.id) { + self.error( + "operation.search_id_invalid", + &format!("{location}.id"), + "search identifiers must be URL-safe kebab case", + ); + } + if source.profile == SourceProfile::LiveReadOnly { + self.error( + "operation.search_live_forbidden", + &location, + "Version one live sources cannot compile a collection search", + ); + } + if let Some(operation) = self.compile_search( + resource, + &properties, + primary_geometry.as_ref(), + &disclosures, + observed_view, + observed_columns.as_ref(), + &location, + search, + ) { + operations.push(operation); + } + } if operations.is_empty() { self.error( "operation.none", @@ -1418,59 +1469,62 @@ impl<'a> Compiler<'a> { root: &str, operation_location: &str, kind: OperationKind, - default_representation: &str, - representation_definitions: &crate::contract::OrderedMap, + default_access_profile: &str, + access_profile_definitions: &crate::contract::OrderedMap, ) -> Option { - let location = if operation_location == "lookup" { + let location = if matches!(operation_location, "lookup" | "search") { root.to_owned() } else { format!("{root}.operations.{operation_location}") }; - if representation_definitions.is_empty() { + if access_profile_definitions.is_empty() { self.error( - "representation.none", - &format!("{location}.representations"), - "an operation must declare at least one finite representation", + "access_profile.none", + &format!("{location}.accessProfiles"), + "an operation must declare at least one finite access profile", ); return None; } - if representation_definitions.len() > MAXIMUM_REPRESENTATIONS_PER_OPERATION { + if access_profile_definitions.len() > MAXIMUM_ACCESS_PROFILES_PER_OPERATION { self.error( - "representation.bound_exceeded", - &format!("{location}.representations"), - "the representation count exceeds the per-operation product ceiling", + "access_profile.bound_exceeded", + &format!("{location}.accessProfiles"), + "the access profile count exceeds the per-operation product ceiling", ); } - if !valid_kebab_identifier(default_representation) - || representation_definitions - .get(default_representation) + if !valid_kebab_identifier(default_access_profile) + || access_profile_definitions + .get(default_access_profile) .is_none() { self.error( - "representation.default_invalid", - &format!("{location}.defaultRepresentation"), - "the explicit default must name exactly one declared representation", + "access_profile.default_invalid", + &format!("{location}.defaultAccessProfile"), + "the explicit default must name exactly one declared access profile", ); } let identifier = match &kind { OperationKind::Read => format!("{}.read", resource.id), OperationKind::List => format!("{}.list", resource.id), OperationKind::Lookup { name } => format!("{}.lookup.{name}", resource.id), + OperationKind::Search { name } => format!("{}.search.{name}", resource.id), }; let pattern = match &kind { OperationKind::List => ConsultationPattern::List, OperationKind::Read => ConsultationPattern::Retrieve, - OperationKind::Lookup { .. } => ConsultationPattern::Search, + OperationKind::Lookup { .. } | OperationKind::Search { .. } => { + ConsultationPattern::Search + } }; let artifact_stem = operation_artifact_stem(&resource.id, &kind); - let mut representations = Vec::with_capacity(representation_definitions.len()); - for (representation_id, definition) in representation_definitions.iter() { - let representation_location = format!("{location}.representations.{representation_id}"); - if !valid_kebab_identifier(representation_id) { + let mut access_profiles = Vec::with_capacity(access_profile_definitions.len()); + for (access_profile_id, definition) in access_profile_definitions.iter() { + let access_profile_location = format!("{location}.accessProfiles.{access_profile_id}"); + if !valid_kebab_identifier(access_profile_id) { self.error( - "representation.id_invalid", - &representation_location, - "representation identifiers must be URL-safe kebab case", + "access_profile.id_invalid", + &access_profile_location, + "access profile identifiers must be URL-safe kebab case", ); } let Some(disclosure) = disclosures @@ -1478,16 +1532,16 @@ impl<'a> Compiler<'a> { .find(|item| item.id == definition.disclosure_profile) else { self.error( - "representation.disclosure_unknown", - &format!("{representation_location}.disclosureProfile"), - "the representation names no disclosure profile", + "access_profile.disclosure_unknown", + &format!("{access_profile_location}.disclosureProfile"), + "the access profile names no disclosure profile", ); continue; }; let Some(access) = self.compile_access( &definition.access, observed_columns, - &representation_location, + &access_profile_location, ) else { continue; }; @@ -1495,13 +1549,13 @@ impl<'a> Compiler<'a> { &mut self.report, disclosure, &access, - matches!(&kind, OperationKind::List), - &representation_location, + matches!(&kind, OperationKind::List | OperationKind::Search { .. }), + &access_profile_location, ); - let representation_artifact_stem = - format!("{artifact_stem}--representation-{representation_id}"); - representations.push(CompiledRepresentation { - id: representation_id.to_owned(), + let access_profile_artifact_stem = + format!("{artifact_stem}--access-profile-{access_profile_id}"); + access_profiles.push(CompiledAccessProfile { + id: access_profile_id.to_owned(), access, disclosure_profile: disclosure.id.clone(), selectable_properties: disclosure.properties.clone(), @@ -1529,32 +1583,32 @@ impl<'a> Compiler<'a> { .collect(), schema_reference: artifact_url( &self.contract.registry.base_uri, - &format!("{representation_artifact_stem}-schema"), + &format!("{access_profile_artifact_stem}-schema"), ), semantic_model_reference: artifact_url( &self.contract.registry.base_uri, - &format!("{representation_artifact_stem}-vocabulary"), + &format!("{access_profile_artifact_stem}-vocabulary"), ), context_reference: artifact_url( &self.contract.registry.base_uri, - &format!("{representation_artifact_stem}-context"), + &format!("{access_profile_artifact_stem}-context"), ), }); } - if representations + if access_profiles .iter() - .any(|representation| matches!(representation.access, CompiledAccess::Public)) - && representations + .any(|access_profile| matches!(access_profile.access, CompiledAccess::Public)) + && access_profiles .iter() - .find(|representation| representation.id == default_representation) - .is_some_and(|representation| { - !matches!(representation.access, CompiledAccess::Public) + .find(|access_profile| access_profile.id == default_access_profile) + .is_some_and(|access_profile| { + !matches!(access_profile.access, CompiledAccess::Public) }) { self.error( - "representation.public_default_required", - &format!("{location}.defaultRepresentation"), - "an operation with a public representation must use a public default", + "access_profile.public_default_required", + &format!("{location}.defaultAccessProfile"), + "an operation with a public access profile must use a public default", ); } Some(CompiledOperation { @@ -1562,8 +1616,8 @@ impl<'a> Compiler<'a> { family: CapabilityFamily::Consultation, pattern, kind, - default_representation: default_representation.to_owned(), - representations, + default_access_profile: default_access_profile.to_owned(), + access_profiles, query: QueryPlan { source: resource.source.source.clone(), view: resource.source.view.clone(), @@ -1599,8 +1653,8 @@ impl<'a> Compiler<'a> { root, "list", OperationKind::List, - &list.default_representation, - &list.representations, + &list.default_access_profile, + &list.access_profiles, )?; let location = format!("{root}.operations.list"); if list.filters.len() > MAXIMUM_LIST_FILTERS { @@ -1617,49 +1671,13 @@ impl<'a> Compiler<'a> { "the governed order-key count exceeds the product ceiling", ); } - if list.filters.is_empty() && list.spatial_query.is_none() && !list.allow_unfiltered { + if list.filters.is_empty() && !list.allow_unfiltered { self.error( "list.no_reachable_query", &location, "a list without filters must allow the empty filter set", ); } - if let Some(spatial_query) = &list.spatial_query { - let Some(geometry) = primary_geometry else { - self.error( - "list.spatial_query_without_geometry", - &format!("{location}.spatialQuery"), - "a bbox query requires a compiled primary geometry", - ); - return Some(operation); - }; - let bbox = &spatial_query.bbox; - if geometry.classification.privacy != "non-personal" { - self.error( - "list.bbox_personal_forbidden", - &format!("{location}.spatialQuery.bbox"), - "the initial bbox search profile permits only non-personal geometry", - ); - } - if bbox.maximum_longitude_span_degrees == 0 - || bbox.maximum_longitude_span_degrees > 360 - || bbox.maximum_latitude_span_degrees == 0 - || bbox.maximum_latitude_span_degrees > 180 - { - self.error( - "list.bbox_bound_invalid", - &format!("{location}.spatialQuery.bbox"), - "bbox spans must be positive and no larger than the CRS84 world extent", - ); - } - operation.pattern = ConsultationPattern::Search; - operation.query.spatial_bbox = Some(CompiledSpatialBboxQuery { - longitude_column: geometry.longitude_column.clone(), - latitude_column: geometry.latitude_column.clone(), - maximum_longitude_span_degrees: bbox.maximum_longitude_span_degrees, - maximum_latitude_span_degrees: bbox.maximum_latitude_span_degrees, - }); - } if list.pagination.default_page_size == 0 || list.pagination.maximum_page_size == 0 || list.pagination.maximum_page_size > MAXIMUM_LIST_PAGE_SIZE @@ -1824,6 +1842,175 @@ impl<'a> Compiler<'a> { Some(operation) } + #[allow(clippy::too_many_arguments)] + fn compile_search( + &mut self, + resource: &crate::contract::ResourceDefinition, + properties: &[CompiledProperty], + primary_geometry: Option<&CompiledPrimaryGeometry>, + disclosures: &[CompiledDisclosureProfile], + observed_view: Option<&crate::model::ObservedView>, + observed_columns: Option<&BTreeSet<&str>>, + location: &str, + search: &crate::contract::SearchOperation, + ) -> Option { + let mut operation = self.compile_simple_operation( + resource, + properties, + primary_geometry, + disclosures, + observed_columns, + location, + "search", + OperationKind::Search { + name: search.id.clone(), + }, + &search.default_access_profile, + &search.access_profiles, + )?; + let Some(geometry) = primary_geometry else { + self.error( + "search.point_bbox_without_geometry", + &format!("{location}.query"), + "a point-bbox search requires one compiled primary geometry", + ); + return Some(operation); + }; + if geometry.classification.privacy != "non-personal" { + self.error( + "search.point_bbox_personal_forbidden", + &format!("{location}.query"), + "the point-bbox search profile permits only non-personal geometry", + ); + } + let SearchQueryDefinition::PointBbox { + maximum_longitude_span_degrees, + maximum_latitude_span_degrees, + } = &search.query; + if *maximum_longitude_span_degrees == 0 + || *maximum_longitude_span_degrees > 360 + || *maximum_latitude_span_degrees == 0 + || *maximum_latitude_span_degrees > 180 + { + self.error( + "search.point_bbox_bound_invalid", + &format!("{location}.query"), + "point-bbox spans must be positive and no larger than the CRS84 world extent", + ); + } + operation.query.spatial_bbox = Some(CompiledSpatialBboxQuery { + longitude_column: geometry.longitude_column.clone(), + latitude_column: geometry.latitude_column.clone(), + maximum_longitude_span_degrees: *maximum_longitude_span_degrees, + maximum_latitude_span_degrees: *maximum_latitude_span_degrees, + }); + + if search.order_by.len() > MAXIMUM_LIST_ORDER_KEYS { + self.error( + "search.order_bound_exceeded", + &format!("{location}.orderBy"), + "the governed order-key count exceeds the product ceiling", + ); + } + let mut order = HashSet::new(); + let mut order_columns = HashSet::new(); + for (index, property_name) in search.order_by.iter().enumerate() { + if !order.insert(property_name.as_str()) { + self.error( + "search.order_duplicate", + &format!("{location}.orderBy"), + "fixed search order keys must be unique", + ); + } + match properties + .iter() + .find(|property| property.name == *property_name) + { + Some(property) => { + if property.transform.is_some() { + self.error( + "search.order_property_transformed", + &format!("{location}.orderBy[{index}]"), + "transformed properties cannot be fixed search order keys", + ); + continue; + } + if !property.source_required { + self.error( + "search.order_property_optional", + &format!("{location}.orderBy"), + "fixed search order properties must be required", + ); + } + if !cursor_order_type_supported(property.data_type) { + self.error( + "search.order_property_type_unsupported", + &format!("{location}.orderBy"), + "fixed search order properties must use a cursor-supported scalar shape", + ); + } + if !order_columns.insert(property.source_column.as_str()) { + self.error( + "search.order_column_duplicate", + &format!("{location}.orderBy"), + "fixed search order properties must resolve to distinct source columns", + ); + } + self.validate_cursor_order_column( + observed_view, + &property.source_column, + property.data_type, + &format!("{location}.orderBy"), + ); + operation + .query + .order_by + .push(property.source_column.clone()); + } + None => self.error( + "search.order_property_unknown", + &format!("{location}.orderBy"), + "a fixed search order key must name a published property", + ), + } + } + let record_identifier = &resource.record_context.record_identifier.source_column; + operation + .query + .order_by + .retain(|column| column != record_identifier); + operation.query.order_by.push(record_identifier.clone()); + self.validate_cursor_order_column( + observed_view, + record_identifier, + DataType::String, + &format!("{location}.orderBy"), + ); + if operation.query.order_by.len() > MAXIMUM_LIST_ORDER_KEYS { + self.error( + "search.order_bound_exceeded", + &format!("{location}.orderBy"), + "fixed search order keys plus the record-identifier tie-breaker exceed the product ceiling", + ); + } + if search.pagination.default_page_size == 0 + || search.pagination.maximum_page_size == 0 + || search.pagination.maximum_page_size > MAXIMUM_LIST_PAGE_SIZE + || search.pagination.default_page_size > search.pagination.maximum_page_size + { + self.error( + "search.pagination_invalid", + &format!("{location}.pagination"), + "page bounds must be positive and the default cannot exceed the maximum", + ); + } + operation.query.pagination = Some(CompiledPagination { + default_page_size: search.pagination.default_page_size, + maximum_page_size: search.pagination.maximum_page_size, + }); + Some(operation) + } + fn validate_cursor_order_column( &mut self, observed_view: Option<&crate::model::ObservedView>, @@ -2059,16 +2246,16 @@ impl<'a> Compiler<'a> { .or_default() .insert(ColumnUse::Selector(selector.name.clone())); } - for representation in &operation.representations { + for access_profile in &operation.access_profiles { if let CompiledAccess::Protected { row_binding: Some(row_binding), .. - } = &representation.access + } = &access_profile.access { uses.entry(&row_binding.source_column).or_default().insert( ColumnUse::RowBinding(format!( "{}:{}", - operation.identifier, representation.id + operation.identifier, access_profile.id )), ); } @@ -2209,10 +2396,13 @@ impl<'a> Compiler<'a> { OperationKind::Lookup { name } => { format!("{root}.operations.lookups.{name}") } + OperationKind::Search { name } => { + format!("{root}.operations.searches.{name}") + } }; - for representation in &mut operation.representations { + for access_profile in &mut operation.access_profiles { let mut referenced = BTreeSet::new(); - referenced.extend(representation.projected_columns.iter().map(String::as_str)); + referenced.extend(access_profile.projected_columns.iter().map(String::as_str)); referenced.extend( operation .query @@ -2235,34 +2425,37 @@ impl<'a> Compiler<'a> { if let CompiledAccess::Protected { row_binding: Some(binding), .. - } = &representation.access + } = &access_profile.access { referenced.insert(&binding.source_column); } - representation.processing_handling = columns + access_profile.processing_handling = columns .iter() .filter(|column| referenced.contains(column.column.as_str())) .fold(Handling::Public, |maximum, column| { maximum.max(column.classification.handling) }); - let representation_location = - format!("{location}.representations.{}", representation.id); - if representation.processing_handling > Handling::Public - && matches!(representation.access, CompiledAccess::Public) + let access_profile_location = + format!("{location}.accessProfiles.{}", access_profile.id); + if access_profile.processing_handling > Handling::Public + && matches!(access_profile.access, CompiledAccess::Public) { self.error( "access.public_nonpublic_forbidden", - &representation_location, - "anonymous representations may process only public-handling reviewed columns", + &access_profile_location, + "anonymous access profiles may process only public-handling reviewed columns", ); } - if representation.processing_handling == Handling::Restricted - && matches!(&operation.kind, OperationKind::List) + if access_profile.processing_handling == Handling::Restricted + && matches!( + &operation.kind, + OperationKind::List | OperationKind::Search { .. } + ) { self.error( "operation.restricted_list_forbidden", - &representation_location, - "restricted reviewed data cannot be processed by a collection list", + &access_profile_location, + "restricted reviewed data cannot be processed by a collection operation", ); } } @@ -2280,9 +2473,9 @@ impl<'a> Compiler<'a> { let has_public = operations.iter().any(|operation| { operation - .representations + .access_profiles .iter() - .any(|representation| matches!(representation.access, CompiledAccess::Public)) + .any(|access_profile| matches!(access_profile.access, CompiledAccess::Public)) }); for (name, visibility) in [ ("resources", self.contract.metadata_visibility.resources), @@ -2299,7 +2492,7 @@ impl<'a> Compiler<'a> { } } // Classification and processing artifacts are projected per finite - // representation. A protected representation is operation-bound even + // access profile. A protected access profile is operation-bound even // when a public sibling permits public metadata for its own profile. } @@ -2345,6 +2538,7 @@ impl<'a> Compiler<'a> { OperationKind::List => reference == "list", OperationKind::Read => reference == "read", OperationKind::Lookup { name } => reference == &format!("lookup:{name}"), + OperationKind::Search { name } => reference == &format!("search:{name}"), }); if !present { self.error( @@ -2414,7 +2608,7 @@ fn revision(value: &T) -> Result { /// Digest the non-circular inventory an institutional classification review /// accepts. Contract revisions, governed file bytes, and review metadata are /// deliberately absent; processed source columns, governed properties, query -/// uses, and finite representation disclosures are present. +/// uses, and finite access profile disclosures are present. pub fn classification_inventory_digest( registry: &CompiledRegistry, ) -> Result { @@ -2490,13 +2684,13 @@ pub fn classification_inventory_digest( #[serde(rename_all = "camelCase")] struct OperationInventory<'a> { kind: &'a OperationKind, - default_representation: &'a str, - representations: Vec>, + default_access_profile: &'a str, + access_profiles: Vec>, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] - struct RepresentationInventory<'a> { + struct AccessProfileInventory<'a> { id: &'a str, access: &'a CompiledAccess, disclosure_profile: &'a str, @@ -2569,19 +2763,19 @@ pub fn classification_inventory_digest( .iter() .map(|operation| OperationInventory { kind: &operation.kind, - default_representation: &operation.default_representation, - representations: operation - .representations + default_access_profile: &operation.default_access_profile, + access_profiles: operation + .access_profiles .iter() - .map(|representation| RepresentationInventory { - id: &representation.id, - access: &representation.access, - disclosure_profile: &representation.disclosure_profile, - selectable_properties: &representation.selectable_properties, - projected_columns: &representation.projected_columns, - processing_handling: representation.processing_handling, - disclosure_handling: representation.disclosure_handling, - transform_inventory: &representation.transform_inventory, + .map(|access_profile| AccessProfileInventory { + id: &access_profile.id, + access: &access_profile.access, + disclosure_profile: &access_profile.disclosure_profile, + selectable_properties: &access_profile.selectable_properties, + projected_columns: &access_profile.projected_columns, + processing_handling: access_profile.processing_handling, + disclosure_handling: access_profile.disclosure_handling, + transform_inventory: &access_profile.transform_inventory, }) .collect(), }) @@ -3093,8 +3287,8 @@ fn projected_columns( ] { push_unique(&mut columns, column); } - // Only the selected finite representation may widen the Registry Core - // projection. This is what lets a public representation prove that it + // Only the selected finite access profile may widen the Registry Core + // projection. This is what lets a public access profile prove that it // never processes a hidden non-public source column. for name in disclosure { if let Some(property) = properties.iter().find(|property| property.name == *name) { @@ -3304,6 +3498,7 @@ fn operation_artifact_stem(resource: &str, kind: &OperationKind) -> String { OperationKind::List => format!("{resource}--list"), OperationKind::Read => format!("{resource}--read"), OperationKind::Lookup { name } => format!("{resource}--lookup-{name}"), + OperationKind::Search { name } => format!("{resource}--search-{name}"), } } @@ -3426,11 +3621,11 @@ pub(crate) mod tests { let second_artifacts = crate::artifacts::generate_artifacts(&second).expect("artifacts"); assert_eq!(first_artifacts, second_artifacts); let operation = &first.resources[0].operations[0]; - let representation = &operation.representations[0]; + let access_profile = &operation.access_profiles[0]; let schema = first_artifacts .artifacts .iter() - .find(|artifact| representation.schema_reference.ends_with(&artifact.id)) + .find(|artifact| access_profile.schema_reference.ends_with(&artifact.id)) .expect("operation schema is mounted by its exact artifact identifier"); assert_eq!(schema.visibility, crate::contract::Visibility::Public); } @@ -3479,8 +3674,8 @@ pub(crate) mod tests { fn every_referenced_selector_codelist_must_be_in_the_governed_closure() { let yaml = valid_contract() .replace( - "read:\n defaultRepresentation: public\n representations:\n public: {access: public, disclosureProfile: public}", - "lookups:\n - id: by-name\n requestBody:\n maximumBytes: 1024\n selectors:\n name: {sourceColumn: name, type: controlled-code, codelist: codelists/selector-names.yaml}\n defaultRepresentation: public\n representations:\n public: {access: public, disclosureProfile: public}", + "read:\n defaultAccessProfile: public\n accessProfiles:\n public: {access: public, disclosureProfile: public}", + "lookups:\n - id: by-name\n requestBody:\n maximumBytes: 1024\n selectors:\n name: {sourceColumn: name, type: controlled-code, codelist: codelists/selector-names.yaml}\n defaultAccessProfile: public\n accessProfiles:\n public: {access: public, disclosureProfile: public}", ) .replace("operationRefs: [read]", "operationRefs: [lookup:by-name]"); let contract = RegistryContract::parse_yaml(&yaml).expect("strict lookup contract"); @@ -3505,8 +3700,8 @@ pub(crate) mod tests { " semanticTerm: local:name\n recordId:\n label: Record identifier\n description: Stable record identifier\n sourceColumn: id\n type: string\n sourceRequired: true\n semanticTerm: local:recordId\n disclosureProfiles", ) .replace( - "read:\n defaultRepresentation: public\n representations:\n public: {access: public, disclosureProfile: public}", - "list:\n defaultRepresentation: public\n representations:\n public: {access: public, disclosureProfile: public}\n filters: []\n allowUnfiltered: true\n orderBy: [recordId, name]\n pagination: {defaultPageSize: 1, maximumPageSize: 10}", + "read:\n defaultAccessProfile: public\n accessProfiles:\n public: {access: public, disclosureProfile: public}", + "list:\n defaultAccessProfile: public\n accessProfiles:\n public: {access: public, disclosureProfile: public}\n filters: []\n allowUnfiltered: true\n orderBy: [recordId, name]\n pagination: {defaultPageSize: 1, maximumPageSize: 10}", ) .replace("operationRefs: [read]", "operationRefs: [list]"); let contract = RegistryContract::parse_yaml(&yaml).expect("strict list contract"); @@ -3523,8 +3718,8 @@ pub(crate) mod tests { fn optional_and_unsupported_cursor_order_columns_are_refused() { let yaml = valid_contract() .replace( - "read:\n defaultRepresentation: public\n representations:\n public: {access: public, disclosureProfile: public}", - "list:\n defaultRepresentation: public\n representations:\n public: {access: public, disclosureProfile: public}\n filters: []\n allowUnfiltered: true\n orderBy: [name]\n pagination: {defaultPageSize: 1, maximumPageSize: 10}", + "read:\n defaultAccessProfile: public\n accessProfiles:\n public: {access: public, disclosureProfile: public}", + "list:\n defaultAccessProfile: public\n accessProfiles:\n public: {access: public, disclosureProfile: public}\n filters: []\n allowUnfiltered: true\n orderBy: [name]\n pagination: {defaultPageSize: 1, maximumPageSize: 10}", ) .replace("operationRefs: [read]", "operationRefs: [list]"); let optional = yaml.replace( @@ -3569,8 +3764,8 @@ pub(crate) mod tests { let filtered = transformed .replace( - "read:\n defaultRepresentation: public\n representations:\n public: {access: public, disclosureProfile: public}", - "list:\n defaultRepresentation: public\n representations:\n public: {access: public, disclosureProfile: public}\n filters:\n - {name: byName, property: name, type: string}\n allowUnfiltered: false\n orderBy: []\n pagination: {defaultPageSize: 1, maximumPageSize: 10}", + "read:\n defaultAccessProfile: public\n accessProfiles:\n public: {access: public, disclosureProfile: public}", + "list:\n defaultAccessProfile: public\n accessProfiles:\n public: {access: public, disclosureProfile: public}\n filters:\n - {name: byName, property: name, type: string}\n allowUnfiltered: false\n orderBy: []\n pagination: {defaultPageSize: 1, maximumPageSize: 10}", ) .replace("operationRefs: [read]", "operationRefs: [list]"); let contract = RegistryContract::parse_yaml(&filtered).expect("strict filter contract"); @@ -3592,8 +3787,8 @@ pub(crate) mod tests { let ordered = transformed .replace( - "read:\n defaultRepresentation: public\n representations:\n public: {access: public, disclosureProfile: public}", - "list:\n defaultRepresentation: public\n representations:\n public: {access: public, disclosureProfile: public}\n filters: []\n allowUnfiltered: true\n orderBy: [name]\n pagination: {defaultPageSize: 1, maximumPageSize: 10}", + "read:\n defaultAccessProfile: public\n accessProfiles:\n public: {access: public, disclosureProfile: public}", + "list:\n defaultAccessProfile: public\n accessProfiles:\n public: {access: public, disclosureProfile: public}\n filters: []\n allowUnfiltered: true\n orderBy: [name]\n pagination: {defaultPageSize: 1, maximumPageSize: 10}", ) .replace("operationRefs: [read]", "operationRefs: [list]"); let contract = RegistryContract::parse_yaml(&ordered).expect("strict order contract"); @@ -3618,8 +3813,8 @@ pub(crate) mod tests { fn sqlite_view_nullable_metadata_does_not_override_required_order_contract() { let yaml = valid_contract() .replace( - "read:\n defaultRepresentation: public\n representations:\n public: {access: public, disclosureProfile: public}", - "list:\n defaultRepresentation: public\n representations:\n public: {access: public, disclosureProfile: public}\n filters: []\n allowUnfiltered: true\n orderBy: [name]\n pagination: {defaultPageSize: 1, maximumPageSize: 10}", + "read:\n defaultAccessProfile: public\n accessProfiles:\n public: {access: public, disclosureProfile: public}", + "list:\n defaultAccessProfile: public\n accessProfiles:\n public: {access: public, disclosureProfile: public}\n filters: []\n allowUnfiltered: true\n orderBy: [name]\n pagination: {defaultPageSize: 1, maximumPageSize: 10}", ) .replace("operationRefs: [read]", "operationRefs: [list]"); let contract = RegistryContract::parse_yaml(&yaml).expect("strict list contract"); @@ -3639,8 +3834,8 @@ pub(crate) mod tests { fn required_record_identifier_tie_breaker_is_included_in_order_cap() { let yaml = valid_contract() .replace( - "read:\n defaultRepresentation: public\n representations:\n public: {access: public, disclosureProfile: public}", - "list:\n defaultRepresentation: public\n representations:\n public: {access: public, disclosureProfile: public}\n filters: []\n allowUnfiltered: true\n orderBy: []\n pagination: {defaultPageSize: 1, maximumPageSize: 10}", + "read:\n defaultAccessProfile: public\n accessProfiles:\n public: {access: public, disclosureProfile: public}", + "list:\n defaultAccessProfile: public\n accessProfiles:\n public: {access: public, disclosureProfile: public}\n filters: []\n allowUnfiltered: true\n orderBy: []\n pagination: {defaultPageSize: 1, maximumPageSize: 10}", ) .replace("operationRefs: [read]", "operationRefs: [list]"); let base = RegistryContract::parse_yaml(&yaml).expect("strict list contract"); @@ -3699,8 +3894,8 @@ pub(crate) mod tests { fn classification_inventory_excludes_presentation_and_runtime_tuning() { let yaml = valid_contract() .replace( - "read:\n defaultRepresentation: public\n representations:\n public: {access: public, disclosureProfile: public}", - "list:\n defaultRepresentation: public\n representations:\n public: {access: public, disclosureProfile: public}\n filters: []\n allowUnfiltered: true\n orderBy: [name]\n pagination: {defaultPageSize: 1, maximumPageSize: 10}", + "read:\n defaultAccessProfile: public\n accessProfiles:\n public: {access: public, disclosureProfile: public}", + "list:\n defaultAccessProfile: public\n accessProfiles:\n public: {access: public, disclosureProfile: public}\n filters: []\n allowUnfiltered: true\n orderBy: [name]\n pagination: {defaultPageSize: 1, maximumPageSize: 10}", ) .replace("operationRefs: [read]", "operationRefs: [list]"); let contract = RegistryContract::parse_yaml(&yaml).expect("strict list contract"); @@ -3722,10 +3917,10 @@ pub(crate) mod tests { .as_mut() .expect("list pagination") .maximum_page_size = 99; - operation.representations[0].schema_reference = "https://elsewhere.invalid/schema".into(); - operation.representations[0].semantic_model_reference = + operation.access_profiles[0].schema_reference = "https://elsewhere.invalid/schema".into(); + operation.access_profiles[0].semantic_model_reference = "https://elsewhere.invalid/vocabulary".into(); - operation.representations[0].context_reference = "https://elsewhere.invalid/context".into(); + operation.access_profiles[0].context_reference = "https://elsewhere.invalid/context".into(); assert_eq!( classification_inventory_digest(&presentation_only).expect("narrow digest"), baseline @@ -3770,7 +3965,7 @@ pub(crate) mod tests { ); let mut transform_inventory_changed = compiled.clone(); - transform_inventory_changed.resources[0].operations[0].representations[0] + transform_inventory_changed.resources[0].operations[0].access_profiles[0] .transform_inventory .push("partial-string:suffix:2".into()); assert_ne!( @@ -3780,7 +3975,7 @@ pub(crate) mod tests { ); let mut access_changed = compiled.clone(); - access_changed.resources[0].operations[0].representations[0].access = + access_changed.resources[0].operations[0].access_profiles[0].access = CompiledAccess::Protected { scope: "registry:changed:read".into(), purpose: None, @@ -3792,7 +3987,7 @@ pub(crate) mod tests { ); let mut disclosure_changed = compiled; - disclosure_changed.resources[0].operations[0].representations[0].disclosure_handling = + disclosure_changed.resources[0].operations[0].access_profiles[0].disclosure_handling = Handling::Internal; assert_ne!( classification_inventory_digest(&disclosure_changed).expect("disclosure digest"), @@ -3891,9 +4086,9 @@ pub(crate) mod tests { #[test] fn governed_query_bounds_cannot_exceed_product_ceilings() { let oversized_list = valid_contract().replace( - "read:\n defaultRepresentation: public\n representations:\n public: {access: public, disclosureProfile: public}", + "read:\n defaultAccessProfile: public\n accessProfiles:\n public: {access: public, disclosureProfile: public}", &format!( - "list:\n defaultRepresentation: public\n representations:\n public: {{access: public, disclosureProfile: public}}\n filters: []\n allowUnfiltered: true\n orderBy: [name]\n pagination: {{defaultPageSize: 1, maximumPageSize: {}}}", + "list:\n defaultAccessProfile: public\n accessProfiles:\n public: {{access: public, disclosureProfile: public}}\n filters: []\n allowUnfiltered: true\n orderBy: [name]\n pagination: {{defaultPageSize: 1, maximumPageSize: {}}}", MAXIMUM_LIST_PAGE_SIZE + 1 ), ); @@ -3911,9 +4106,9 @@ pub(crate) mod tests { .any(|item| item.code == "list.pagination_invalid")); let oversized_lookup = valid_contract().replace( - "read:\n defaultRepresentation: public\n representations:\n public: {access: public, disclosureProfile: public}", + "read:\n defaultAccessProfile: public\n accessProfiles:\n public: {access: public, disclosureProfile: public}", &format!( - "lookups:\n - id: by-name\n requestBody:\n maximumBytes: {}\n selectors:\n name: {{sourceColumn: name, type: string, maximumBytes: 32}}\n defaultRepresentation: public\n representations:\n public: {{access: {{scope: registry:records:lookup}}, disclosureProfile: public}}", + "lookups:\n - id: by-name\n requestBody:\n maximumBytes: {}\n selectors:\n name: {{sourceColumn: name, type: string, maximumBytes: 32}}\n defaultAccessProfile: public\n accessProfiles:\n public: {{access: {{scope: registry:records:lookup}}, disclosureProfile: public}}", MAXIMUM_LOOKUP_REQUEST_BODY_BYTES + 1 ), ); @@ -3989,44 +4184,44 @@ pub(crate) mod tests { } assert_refused(&parse_value(disclosures_value), "disclosure.bound_exceeded"); - let mut representations_value = serde_json::to_value(&base).expect("contract serializes"); - let representations = representations_value - .pointer_mut("/resources/0/operations/read/representations") + let mut access_profiles_value = serde_json::to_value(&base).expect("contract serializes"); + let access_profiles = access_profiles_value + .pointer_mut("/resources/0/operations/read/accessProfiles") .and_then(serde_json::Value::as_object_mut) - .expect("representations object"); - let representation = representations + .expect("access_profiles object"); + let access_profile = access_profiles .get("public") - .expect("public representation") + .expect("public access profile") .clone(); - for index in 1..=MAXIMUM_REPRESENTATIONS_PER_OPERATION { - representations.insert(format!("profile-{index}"), representation.clone()); + for index in 1..=MAXIMUM_ACCESS_PROFILES_PER_OPERATION { + access_profiles.insert(format!("profile-{index}"), access_profile.clone()); } assert_refused( - &parse_value(representations_value), - "representation.bound_exceeded", + &parse_value(access_profiles_value), + "access_profile.bound_exceeded", ); - let mut registry_representations_value = + let mut registry_access_profiles_value = serde_json::to_value(&base).expect("contract serializes"); - let registry_representations = registry_representations_value - .pointer_mut("/resources/0/operations/read/representations") + let registry_access_profiles = registry_access_profiles_value + .pointer_mut("/resources/0/operations/read/accessProfiles") .and_then(serde_json::Value::as_object_mut) - .expect("representations object"); - let representation = registry_representations + .expect("access_profiles object"); + let access_profile = registry_access_profiles .get("public") - .expect("public representation") + .expect("public access profile") .clone(); - for index in 1..=MAXIMUM_REPRESENTATION_EXECUTORS_PER_REGISTRY { - registry_representations.insert(format!("profile-{index}"), representation.clone()); + for index in 1..=MAXIMUM_ACCESS_PROFILE_EXECUTORS_PER_REGISTRY { + registry_access_profiles.insert(format!("profile-{index}"), access_profile.clone()); } assert_refused( - &parse_value(registry_representations_value), - "representation.registry_bound_exceeded", + &parse_value(registry_access_profiles_value), + "access_profile.registry_bound_exceeded", ); let list_yaml = valid_contract().replace( - "read:\n defaultRepresentation: public\n representations:\n public: {access: public, disclosureProfile: public}", - "list:\n defaultRepresentation: public\n representations:\n public: {access: public, disclosureProfile: public}\n filters: []\n allowUnfiltered: true\n orderBy: [name]\n pagination: {defaultPageSize: 1, maximumPageSize: 1}", + "read:\n defaultAccessProfile: public\n accessProfiles:\n public: {access: public, disclosureProfile: public}", + "list:\n defaultAccessProfile: public\n accessProfiles:\n public: {access: public, disclosureProfile: public}\n filters: []\n allowUnfiltered: true\n orderBy: [name]\n pagination: {defaultPageSize: 1, maximumPageSize: 1}", ); let list_contract = RegistryContract::parse_yaml(&list_yaml).expect("strict list contract"); let mut filters_value = serde_json::to_value(&list_contract).expect("contract serializes"); @@ -4055,10 +4250,10 @@ pub(crate) mod tests { } #[test] - fn an_operation_with_a_public_representation_requires_a_public_default() { + fn an_operation_with_a_public_access_profile_requires_a_public_default() { let contract = RegistryContract::parse_yaml(&valid_contract().replace( - "defaultRepresentation: public\n representations:\n public: {access: public, disclosureProfile: public}", - "defaultRepresentation: protected\n representations:\n public: {access: public, disclosureProfile: public}\n protected: {access: {scope: registry:record:protected}, disclosureProfile: public}", + "defaultAccessProfile: public\n accessProfiles:\n public: {access: public, disclosureProfile: public}", + "defaultAccessProfile: protected\n accessProfiles:\n public: {access: public, disclosureProfile: public}\n protected: {access: {scope: registry:record:protected}, disclosureProfile: public}", )) .expect("strict contract"); let report = compile_contract(&contract, &[observed_schema()], CompileProfile::Production) @@ -4066,24 +4261,24 @@ pub(crate) mod tests { assert!(report .diagnostics .iter() - .any(|diagnostic| diagnostic.code == "representation.public_default_required")); + .any(|diagnostic| diagnostic.code == "access_profile.public_default_required")); } #[test] - fn one_operation_compiles_finite_representations_with_distinct_handling() { - let contract = RegistryContract::parse_yaml(&governed_representations_contract()) - .expect("strict representation contract"); + fn one_operation_compiles_finite_access_profiles_with_distinct_handling() { + let contract = RegistryContract::parse_yaml(&governed_access_profiles_contract()) + .expect("strict access profile contract"); let compiled = compile_contract(&contract, &[observed_schema()], CompileProfile::Production) - .expect("representations compile"); + .expect("access_profiles compile"); let resource = &compiled.resources[0]; assert_eq!(resource.operations.len(), 1); let operation = &resource.operations[0]; assert_eq!(operation.identifier, "record.read"); - assert_eq!(operation.default_representation, "limited"); - assert_eq!(operation.representations.len(), 2); - let limited = &operation.representations[0]; - let full = &operation.representations[1]; + assert_eq!(operation.default_access_profile, "limited"); + assert_eq!(operation.access_profiles.len(), 2); + let limited = &operation.access_profiles[0]; + let full = &operation.access_profiles[1]; assert_eq!(limited.id, "limited"); assert_eq!(limited.disclosure_handling, Handling::Confidential); assert_eq!(limited.processing_handling, Handling::Restricted); @@ -4115,7 +4310,7 @@ pub(crate) mod tests { #[test] fn transformed_and_multiply_bound_columns_require_explicit_review() { - let yaml = governed_representations_contract().replace( + let yaml = governed_access_profiles_contract().replace( " sourceColumnClassifications:\n name: {privacy: identifying, institutional: restricted, handling: restricted, status: reviewed}\n", " sourceColumnClassifications: {}\n", ); @@ -4128,10 +4323,10 @@ pub(crate) mod tests { } #[test] - fn representation_default_and_transform_parameters_fail_closed() { - let invalid_default = governed_representations_contract().replace( - "defaultRepresentation: limited", - "defaultRepresentation: absent", + fn access_profile_default_and_transform_parameters_fail_closed() { + let invalid_default = governed_access_profiles_contract().replace( + "defaultAccessProfile: limited", + "defaultAccessProfile: absent", ); let contract = RegistryContract::parse_yaml(&invalid_default).expect("strict contract"); let report = compile_contract(&contract, &[observed_schema()], CompileProfile::Production) @@ -4139,10 +4334,10 @@ pub(crate) mod tests { assert!(report .diagnostics .iter() - .any(|diagnostic| diagnostic.code == "representation.default_invalid")); + .any(|diagnostic| diagnostic.code == "access_profile.default_invalid")); for characters in [0, MAXIMUM_PARTIAL_STRING_CHARACTERS + 1] { - let yaml = governed_representations_contract() + let yaml = governed_access_profiles_contract() .replace("characters: 4", &format!("characters: {characters}")); let contract = RegistryContract::parse_yaml(&yaml).expect("strict contract"); let report = @@ -4155,8 +4350,8 @@ pub(crate) mod tests { } #[test] - fn public_masked_representation_cannot_process_restricted_source() { - let yaml = governed_representations_contract() + fn public_masked_access_profile_cannot_process_restricted_source() { + let yaml = governed_access_profiles_contract() .replace( "classification: {privacy: partially-revealed-identifying, institutional: confidential, handling: confidential, status: reviewed}", "classification: {privacy: partially-revealed-identifying, institutional: public, handling: public, status: reviewed}", @@ -4176,7 +4371,7 @@ pub(crate) mod tests { #[test] fn date_precision_is_typed_and_closed() { - let yaml = governed_representations_contract() + let yaml = governed_access_profiles_contract() .replace("type: string\n sourceRequired: true\n semanticTerm: local:maskedName\n classification: {privacy: partially-revealed-identifying, institutional: confidential, handling: confidential, status: reviewed}\n transform: {kind: partial-string, reveal: suffix, characters: 4}", "type: year-month\n sourceRequired: true\n semanticTerm: local:maskedName\n classification: {privacy: partially-revealed-identifying, institutional: confidential, handling: confidential, status: reviewed}\n transform: {kind: date-precision, sourceType: date-time, precision: year-month}"); let contract = RegistryContract::parse_yaml(&yaml).expect("strict date transform"); let compiled = @@ -4236,7 +4431,7 @@ pub(crate) mod tests { let CompiledAccess::Protected { row_binding: Some(binding), .. - } = &operation.representations[0].access + } = &operation.access_profiles[0].access else { panic!("row-bound protected access expected"); }; @@ -4268,9 +4463,9 @@ pub(crate) mod tests { &governed_files(), ) .expect("legacy contract compiles"); - assert_eq!(compiled.resources[0].operations[0].representations.len(), 1); + assert_eq!(compiled.resources[0].operations[0].access_profiles.len(), 1); assert_eq!( - compiled.resources[0].operations[0].representations[0].id, + compiled.resources[0].operations[0].access_profiles[0].id, "public" ); } @@ -4292,6 +4487,13 @@ pub(crate) mod tests { assert_eq!(geometry.longitude_column, "longitude"); let operation = &resource.operations[0]; assert_eq!(operation.pattern, ConsultationPattern::Search); + assert_eq!( + operation.kind, + OperationKind::Search { + name: "within-bbox".into() + } + ); + assert_eq!(operation.identifier, "record.search.within-bbox"); assert!(!operation.query.allow_unfiltered); assert_eq!( operation @@ -4302,12 +4504,12 @@ pub(crate) mod tests { .maximum_longitude_span_degrees, 10 ); - let representation = &operation.representations[0]; - assert!(representation + let access_profile = &operation.access_profiles[0]; + assert!(access_profile .projected_columns .iter() .any(|column| column == "longitude")); - assert!(representation + assert!(access_profile .selectable_properties .iter() .any(|property| property == "location")); @@ -4318,7 +4520,7 @@ pub(crate) mod tests { .contains(&ColumnUse::GeometryLatitude("location".into())) && account .uses - .contains(&ColumnUse::SpatialBbox("record.list".into())) + .contains(&ColumnUse::SpatialBbox("record.search.within-bbox".into())) })); } @@ -4345,14 +4547,21 @@ pub(crate) mod tests { collision["resources"][0]["primaryGeometry"]["name"] = serde_json::json!("name"); assert_code(collision, "geometry.name_collision"); + let mut without_geometry = spatial_contract_value(true); + without_geometry["resources"][0] + .as_object_mut() + .expect("resource object") + .remove("primaryGeometry"); + assert_code(without_geometry, "search.point_bbox_without_geometry"); + let mut carrier_collision = spatial_contract_value(true); carrier_collision["resources"][0]["primaryGeometry"]["source"]["longitudeColumn"] = serde_json::json!("name"); assert_code(carrier_collision, "geometry.column_collision"); let mut row_binding_collision = spatial_contract_value(true); - row_binding_collision["resources"][0]["operations"]["list"]["representations"]["public"] - ["access"] = serde_json::json!({ + row_binding_collision["resources"][0]["operations"]["searches"][0]["accessProfiles"] + ["public"]["access"] = serde_json::json!({ "scope": "registry:records:list", "authorityRowBinding": {"principal": true, "sourceColumn": "longitude"} }); @@ -4363,15 +4572,15 @@ pub(crate) mod tests { assert_code(wrong_crs, "geometry.crs_unsupported"); let mut oversized = spatial_contract_value(true); - oversized["resources"][0]["operations"]["list"]["spatialQuery"]["bbox"] + oversized["resources"][0]["operations"]["searches"][0]["query"] ["maximumLatitudeSpanDegrees"] = serde_json::json!(181); - assert_code(oversized, "list.bbox_bound_invalid"); + assert_code(oversized, "search.point_bbox_bound_invalid"); let mut personal = spatial_contract_value(true); personal["resources"][0]["primaryGeometry"]["classification"] = serde_json::json!({ "privacy": "personal" }); - assert_code(personal, "list.bbox_personal_forbidden"); + assert_code(personal, "search.point_bbox_personal_forbidden"); let mut personal_carrier = spatial_contract_value(true); personal_carrier["resources"][0]["sourceColumnClassifications"]["longitude"] = @@ -4396,13 +4605,41 @@ pub(crate) mod tests { properties.insert(format!("name{index}"), template.clone()); } assert_code(too_many_properties, "property.bound_exceeded"); + + let mut duplicate_search = spatial_contract_value(true); + let duplicate = duplicate_search["resources"][0]["operations"]["searches"][0].clone(); + duplicate_search["resources"][0]["operations"]["searches"] + .as_array_mut() + .expect("search array") + .push(duplicate); + assert_code(duplicate_search, "operation.search_id_duplicate"); + + let mut too_many_searches = spatial_contract_value(true); + let template = too_many_searches["resources"][0]["operations"]["searches"][0].clone(); + let searches = too_many_searches["resources"][0]["operations"]["searches"] + .as_array_mut() + .expect("search array"); + for index in 1..=MAXIMUM_SEARCHES_PER_RESOURCE { + let mut search = template.clone(); + search["id"] = serde_json::json!(format!("within-bbox-{index}")); + searches.push(search); + } + assert_code(too_many_searches, "operation.search_bound_exceeded"); } #[test] - fn geometry_disclosure_is_representation_scoped() { - let mut undisclosed = spatial_contract_value(false); + fn geometry_disclosure_is_access_profile_scoped() { + let mut undisclosed = spatial_contract_value(true); undisclosed["resources"][0]["disclosureProfiles"]["public"]["properties"] = serde_json::json!(["name"]); + undisclosed["resources"][0]["primaryGeometry"]["classification"]["handling"] = + serde_json::json!("internal"); + undisclosed["resources"][0]["sourceColumnClassifications"]["longitude"] = + serde_json::json!({"handling": "internal"}); + undisclosed["resources"][0]["sourceColumnClassifications"]["latitude"] = + serde_json::json!({"handling": "internal"}); + undisclosed["resources"][0]["operations"]["searches"][0]["accessProfiles"]["public"] + ["access"] = serde_json::json!({"scope": "registry:records:search"}); let contract = serde_json::from_value::(undisclosed).expect("strict contract value"); let governed_files = governed_files_for(&contract); @@ -4412,16 +4649,49 @@ pub(crate) mod tests { CompileProfile::Production, &governed_files, ) - .expect("geometry may remain outside one governed representation"); - let representation = &compiled.resources[0].operations[0].representations[0]; - assert!(!representation + .expect("geometry may remain outside one governed access profile"); + let access_profile = &compiled.resources[0].operations[0].access_profiles[0]; + assert!(!access_profile .selectable_properties .iter() .any(|property| property == "location")); - assert!(!representation + assert!(!access_profile .projected_columns .iter() .any(|column| column == "longitude" || column == "latitude")); + assert_eq!(access_profile.processing_handling, Handling::Internal); + } + + #[test] + fn named_search_query_and_access_profiles_remain_operation_bound() { + let mut value = spatial_contract_value(true); + let mut protected = value["resources"][0]["operations"]["searches"][0].clone(); + protected["id"] = serde_json::json!("protected-bbox"); + protected["accessProfiles"]["public"]["access"] = + serde_json::json!({"scope": "registry:records:search:protected"}); + value["resources"][0]["operations"]["searches"] + .as_array_mut() + .expect("search array") + .push(protected); + let contract = serde_json::from_value::(value).expect("strict contract"); + let governed_files = governed_files_for(&contract); + let compiled = compile_contract_with_governed_files( + &contract, + &[spatial_observed_schema()], + CompileProfile::Production, + &governed_files, + ) + .expect("independently governed searches compile"); + + assert!(matches!( + compiled.resources[0].operations[0].access_profiles[0].access, + CompiledAccess::Public + )); + assert!(matches!( + &compiled.resources[0].operations[1].access_profiles[0].access, + CompiledAccess::Protected { scope, .. } + if scope == "registry:records:search:protected" + )); } #[test] @@ -4502,26 +4772,26 @@ pub(crate) mod tests { serde_json::json!(["name", "location"]); if list { value["resources"][0]["operations"] = serde_json::json!({ - "list": { - "defaultRepresentation": "public", - "representations": { + "searches": [{ + "id": "within-bbox", + "query": { + "kind": "point-bbox", + "maximumLongitudeSpanDegrees": 10, + "maximumLatitudeSpanDegrees": 10 + }, + "defaultAccessProfile": "public", + "accessProfiles": { "public": { "access": "public", "disclosureProfile": "public" } }, - "filters": [], - "spatialQuery": {"bbox": { - "maximumLongitudeSpanDegrees": 10, - "maximumLatitudeSpanDegrees": 10 - }}, - "allowUnfiltered": false, "orderBy": ["name"], "pagination": {"defaultPageSize": 2, "maximumPageSize": 10} - } + }] }); value["resources"][0]["processingDescriptions"][0]["operationRefs"] = - serde_json::json!(["list"]); + serde_json::json!(["search:within-bbox"]); } value } @@ -4559,7 +4829,7 @@ pub(crate) mod tests { ), ( "governance/review-rationale", - "reviewed classification and representation design\n", + "reviewed classification and access profile design\n", ), ( "governance/processing.dpv.yaml", @@ -4580,7 +4850,7 @@ pub(crate) mod tests { files } - fn governed_representations_contract() -> String { + fn governed_access_profiles_contract() -> String { valid_contract() .replace( " sourceColumnClassifications: {}", @@ -4591,8 +4861,8 @@ pub(crate) mod tests { " semanticTerm: local:name\n classification: {privacy: identifying, institutional: restricted, handling: restricted, status: reviewed}\n maskedName:\n label: Masked name\n description: Partially revealed Record name\n sourceColumn: name\n type: string\n sourceRequired: true\n semanticTerm: local:maskedName\n classification: {privacy: partially-revealed-identifying, institutional: confidential, handling: confidential, status: reviewed}\n transform: {kind: partial-string, reveal: suffix, characters: 4}\n disclosureProfiles:\n limited: {properties: [maskedName]}\n full: {properties: [name]}", ) .replace( - " read:\n defaultRepresentation: public\n representations:\n public: {access: public, disclosureProfile: public}", - " read:\n defaultRepresentation: limited\n representations:\n limited:\n access: {scope: registry:records:limited}\n disclosureProfile: limited\n full:\n access: {scope: registry:records:full}\n disclosureProfile: full", + " read:\n defaultAccessProfile: public\n accessProfiles:\n public: {access: public, disclosureProfile: public}", + " read:\n defaultAccessProfile: limited\n accessProfiles:\n limited:\n access: {scope: registry:records:limited}\n disclosureProfile: limited\n full:\n access: {scope: registry:records:full}\n disclosureProfile: full", ) .replace( "metadataVisibility: {service: public, resources: public, semantics: public, classifications: public, processing: public}", @@ -4646,8 +4916,8 @@ resources: disclosureProfiles: {public: {properties: [name]}} operations: read: - defaultRepresentation: public - representations: + defaultAccessProfile: public + accessProfiles: public: {access: public, disclosureProfile: public} processingDescriptions: - id: statutory-publication diff --git a/crates/registry-relay-v2/src/contract.rs b/crates/registry-relay-v2/src/contract.rs index 3bb13b0e9..3f5a27694 100644 --- a/crates/registry-relay-v2/src/contract.rs +++ b/crates/registry-relay-v2/src/contract.rs @@ -413,17 +413,17 @@ pub struct Operations { pub read: Option, #[serde(default)] pub lookups: Vec, + #[serde(default)] + pub searches: Vec, } #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] #[serde(deny_unknown_fields, rename_all = "camelCase")] pub struct ListOperation { - pub default_representation: String, - pub representations: OrderedMap, + pub default_access_profile: String, + pub access_profiles: OrderedMap, #[serde(default)] pub filters: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub spatial_query: Option, pub allow_unfiltered: bool, pub order_by: Vec, pub pagination: Pagination, @@ -432,8 +432,8 @@ pub struct ListOperation { #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] #[serde(deny_unknown_fields, rename_all = "camelCase")] pub struct RecordOperation { - pub default_representation: String, - pub representations: OrderedMap, + pub default_access_profile: String, + pub access_profiles: OrderedMap, } #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] @@ -441,28 +441,40 @@ pub struct RecordOperation { pub struct LookupOperation { pub id: String, pub request_body: LookupRequestBody, - pub default_representation: String, - pub representations: OrderedMap, + pub default_access_profile: String, + pub access_profiles: OrderedMap, } #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] #[serde(deny_unknown_fields, rename_all = "camelCase")] -pub struct RepresentationDefinition { - pub access: AccessRule, - pub disclosure_profile: String, +pub struct SearchOperation { + pub id: String, + pub query: SearchQueryDefinition, + pub default_access_profile: String, + pub access_profiles: OrderedMap, + pub order_by: Vec, + pub pagination: Pagination, } #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -pub struct SpatialQuery { - pub bbox: BboxQuery, +#[serde( + tag = "kind", + rename_all = "kebab-case", + rename_all_fields = "camelCase", + deny_unknown_fields +)] +pub enum SearchQueryDefinition { + PointBbox { + maximum_longitude_span_degrees: u16, + maximum_latitude_span_degrees: u16, + }, } #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] #[serde(deny_unknown_fields, rename_all = "camelCase")] -pub struct BboxQuery { - pub maximum_longitude_span_degrees: u16, - pub maximum_latitude_span_degrees: u16, +pub struct AccessProfileDefinition { + pub access: AccessRule, + pub disclosure_profile: String, } #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] @@ -917,9 +929,41 @@ disclosureProfiles: {} #[test] fn legacy_single_profile_operation_shape_is_not_accepted() { let yaml = crate::compiler::tests::valid_contract().replace( - " defaultRepresentation: public\n representations:\n public: {access: public, disclosureProfile: public}", + " defaultAccessProfile: public\n accessProfiles:\n public: {access: public, disclosureProfile: public}", " access: public\n disclosureProfile: public", ); assert!(RegistryContract::parse_yaml(&yaml).is_err()); } + + #[test] + fn old_representation_keys_are_rejected_without_aliases() { + let yaml = crate::compiler::tests::valid_contract() + .replace("defaultAccessProfile", "defaultRepresentation") + .replace("accessProfiles", "representations"); + assert!(RegistryContract::parse_yaml(&yaml).is_err()); + } + + #[test] + fn list_spatial_query_is_rejected_without_a_compatibility_lane() { + let yaml = crate::compiler::tests::valid_contract().replace( + " read:\n defaultAccessProfile: public\n accessProfiles:\n public: {access: public, disclosureProfile: public}", + " list:\n defaultAccessProfile: public\n accessProfiles:\n public: {access: public, disclosureProfile: public}\n filters: []\n spatialQuery: {bbox: {maximumLongitudeSpanDegrees: 10, maximumLatitudeSpanDegrees: 10}}\n allowUnfiltered: false\n orderBy: [name]\n pagination: {defaultPageSize: 10, maximumPageSize: 100}", + ); + assert!(RegistryContract::parse_yaml(&yaml).is_err()); + } + + #[test] + fn named_search_query_is_a_closed_point_bbox_shape() { + let contract = crate::compiler::tests::spatial_contract(true); + let mut value = serde_json::to_value(contract).expect("contract serializes"); + value["resources"][0]["operations"]["searches"][0]["query"]["predicate"] = + serde_json::json!("arbitrary"); + assert!(serde_json::from_value::(value).is_err()); + + let contract = crate::compiler::tests::spatial_contract(true); + let mut value = serde_json::to_value(contract).expect("contract serializes"); + value["resources"][0]["operations"]["searches"][0]["query"]["kind"] = + serde_json::json!("generic-filter"); + assert!(serde_json::from_value::(value).is_err()); + } } diff --git a/crates/registry-relay-v2/src/cursor.rs b/crates/registry-relay-v2/src/cursor.rs index 04a6ddfe9..a7b09f9ef 100644 --- a/crates/registry-relay-v2/src/cursor.rs +++ b/crates/registry-relay-v2/src/cursor.rs @@ -34,7 +34,7 @@ pub struct CursorPayload { pub contract_revision: String, pub source_revision: String, pub operation: String, - pub representation: String, + pub access_profile: String, pub disclosure_profile: String, pub transforms_digest: String, pub filters_digest: String, @@ -51,7 +51,7 @@ pub struct CursorPayload { #[serde(default = "default_response_format")] pub response_format: String, #[serde(default)] - pub response_profile: Option, + pub format_profile: Option, pub last_record_identifier: String, #[serde(default)] pub page_size: u32, @@ -76,7 +76,7 @@ pub enum CursorValue { #[derive(Clone, Debug, PartialEq, Eq)] pub struct CursorBindings { - pub representation: String, + pub access_profile: String, pub disclosure_profile: String, pub transforms_digest: String, pub filters_digest: String, @@ -101,7 +101,7 @@ impl CursorPayload { contract_revision, source_revision, operation, - representation: bindings.representation, + access_profile: bindings.access_profile, disclosure_profile: bindings.disclosure_profile, transforms_digest: bindings.transforms_digest, filters_digest: bindings.filters_digest, @@ -110,7 +110,7 @@ impl CursorPayload { order_digest: bindings.order_digest, bbox: None, response_format: default_response_format(), - response_profile: None, + format_profile: None, last_record_identifier: bindings.last_record_identifier, page_size: 0, filters: BTreeMap::new(), @@ -139,11 +139,11 @@ impl CursorPayload { mut self, bbox: Option<[String; 4]>, response_format: String, - response_profile: Option, + format_profile: Option, ) -> Self { self.bbox = bbox; self.response_format = response_format; - self.response_profile = response_profile; + self.format_profile = format_profile; self } } @@ -276,7 +276,7 @@ pub fn require_same_request( if cursor.contract_revision != request.contract_revision || cursor.source_revision != request.source_revision || cursor.operation != request.operation - || cursor.representation != request.representation + || cursor.access_profile != request.access_profile || cursor.disclosure_profile != request.disclosure_profile || cursor.transforms_digest != request.transforms_digest || cursor.filters_digest != request.filters_digest @@ -285,7 +285,7 @@ pub fn require_same_request( || cursor.order_digest != request.order_digest || cursor.bbox != request.bbox || cursor.response_format != request.response_format - || cursor.response_profile != request.response_profile + || cursor.format_profile != request.format_profile { return Err(CursorError::Mismatch); } @@ -315,7 +315,7 @@ mod tests { "sha256:source".to_owned(), "resource.list".to_owned(), CursorBindings { - representation: "public".to_owned(), + access_profile: "public".to_owned(), disclosure_profile: "public".to_owned(), transforms_digest: "sha256:transforms".to_owned(), filters_digest: "sha256:filters".to_owned(), @@ -415,9 +415,9 @@ mod tests { } #[test] - fn cursor_cannot_cross_representation_disclosure_or_transform_contexts() { + fn cursor_cannot_cross_access_profile_disclosure_or_transform_contexts() { let alterations: [fn(&mut CursorPayload); 3] = [ - |payload: &mut CursorPayload| payload.representation = "caseworker".to_owned(), + |payload: &mut CursorPayload| payload.access_profile = "caseworker".to_owned(), |payload: &mut CursorPayload| { payload.disclosure_profile = "caseworker".to_owned(); }, @@ -436,7 +436,7 @@ mod tests { } #[test] - fn cursor_cannot_cross_spatial_or_representation_contexts() { + fn cursor_cannot_cross_spatial_or_format_contexts() { let spatial = payload().with_response_context( Some([ "100".to_owned(), @@ -455,12 +455,19 @@ mod tests { ); let mut changed_profile = spatial.clone(); - changed_profile.response_profile = Some("rfc7946".to_owned()); + changed_profile.format_profile = Some("rfc7946".to_owned()); assert_eq!( require_same_request(&spatial, &changed_profile), Err(CursorError::Mismatch) ); + let mut changed_format = spatial.clone(); + changed_format.response_format = "json".to_owned(); + assert_eq!( + require_same_request(&spatial, &changed_format), + Err(CursorError::Mismatch) + ); + let key = CursorKey::new(vec![7; 32]).expect("key is sufficient"); let encoded = encode(&key, &spatial).expect("spatial cursor encodes"); assert_eq!( diff --git a/crates/registry-relay-v2/src/diff.rs b/crates/registry-relay-v2/src/diff.rs index 8834982c5..9009b8404 100644 --- a/crates/registry-relay-v2/src/diff.rs +++ b/crates/registry-relay-v2/src/diff.rs @@ -58,9 +58,9 @@ pub enum ChangeClass { HandlingTightened, OperationAdded, OperationRemoved, - RepresentationAdded, - RepresentationRemoved, - DefaultRepresentationChanged, + AccessProfileAdded, + AccessProfileRemoved, + DefaultAccessProfileChanged, DisclosureExpanded, DisclosureNarrowed, DisclosureProfileChanged, @@ -475,51 +475,51 @@ fn diff_operation( location: &str, changes: &mut Vec, ) { - if previous.default_representation != current.default_representation { + if previous.default_access_profile != current.default_access_profile { push( changes, - ChangeClass::DefaultRepresentationChanged, + ChangeClass::DefaultAccessProfileChanged, ChangeImpact::Breaking, - format!("{location}.defaultRepresentation"), - "the representation selected when the caller omits an explicit choice changed", + format!("{location}.defaultAccessProfile"), + "the access profile selected when the caller omits an explicit choice changed", ); } - let before_representations = previous - .representations + let before_access_profiles = previous + .access_profiles .iter() - .map(|representation| (representation.id.as_str(), representation)) + .map(|access_profile| (access_profile.id.as_str(), access_profile)) .collect::>(); - let after_representations = current - .representations + let after_access_profiles = current + .access_profiles .iter() - .map(|representation| (representation.id.as_str(), representation)) + .map(|access_profile| (access_profile.id.as_str(), access_profile)) .collect::>(); - for id in before_representations + for id in before_access_profiles .keys() - .chain(after_representations.keys()) + .chain(after_access_profiles.keys()) .collect::>() { - let representation_location = format!("{location}.representations.{id}"); + let access_profile_location = format!("{location}.accessProfiles.{id}"); match ( - before_representations.get(*id), - after_representations.get(*id), + before_access_profiles.get(*id), + after_access_profiles.get(*id), ) { (None, Some(_)) => push( changes, - ChangeClass::RepresentationAdded, + ChangeClass::AccessProfileAdded, ChangeImpact::Widening, - representation_location, - "a callable representation was added to the operation", + access_profile_location, + "a callable access profile was added to the operation", ), (Some(_), None) => push( changes, - ChangeClass::RepresentationRemoved, + ChangeClass::AccessProfileRemoved, ChangeImpact::Breaking, - representation_location, - "a callable representation was removed from the operation", + access_profile_location, + "a callable access profile was removed from the operation", ), (Some(before), Some(after)) => { - diff_representation(before, after, &representation_location, changes); + diff_access_profile(before, after, &access_profile_location, changes); } (None, None) => unreachable!(), } @@ -625,9 +625,9 @@ fn diff_operation( ); } -fn diff_representation( - previous: &crate::model::CompiledRepresentation, - current: &crate::model::CompiledRepresentation, +fn diff_access_profile( + previous: &crate::model::CompiledAccessProfile, + current: &crate::model::CompiledAccessProfile, location: &str, changes: &mut Vec, ) { @@ -682,7 +682,7 @@ fn diff_representation( ChangeClass::TransformationChanged, ChangeImpact::Breaking, format!("{location}.transforms"), - "the representation transformation inventory changed", + "the access profile transformation inventory changed", ); } if previous.processing_handling != current.processing_handling @@ -693,7 +693,7 @@ fn diff_representation( ChangeClass::ClassificationChanged, ChangeImpact::Breaking, format!("{location}.handling"), - "the representation processing or disclosure handling floor changed", + "the access profile processing or disclosure handling floor changed", ); } diff_access(&previous.access, ¤t.access, location, changes); @@ -710,18 +710,18 @@ fn diff_spatial_query( changes, ChangeClass::SpatialQueryAdded, ChangeImpact::Widening, - format!("{location}.spatialQuery.bbox"), + format!("{location}.query"), "an exact point bbox query was added", ), (Some(_), None) => push( changes, ChangeClass::SpatialQueryRemoved, ChangeImpact::Breaking, - format!("{location}.spatialQuery.bbox"), + format!("{location}.query"), "the exact point bbox query was removed", ), (Some(before), Some(after)) if before != after => { - let location = format!("{location}.spatialQuery.bbox"); + let location = format!("{location}.query"); if before.longitude_column != after.longitude_column || before.latitude_column != after.latitude_column { @@ -1075,6 +1075,18 @@ mod tests { .expect("contract compiles") } + fn compiled_spatial() -> CompiledRegistry { + let contract = compiler_tests::spatial_contract(true); + let governed_files = compiler_tests::governed_files_for(&contract); + compile_contract_with_governed_files( + &contract, + &[compiler_tests::spatial_observed_schema()], + CompileProfile::Production, + &governed_files, + ) + .expect("spatial contract compiles") + } + #[test] fn visibility_order_is_security_monotonic() { assert!(visibility_rank(Visibility::Public) < visibility_rank(Visibility::OperationBound)); @@ -1102,17 +1114,17 @@ mod tests { } #[test] - fn representations_transforms_defaults_and_review_bindings_are_reported() { + fn access_profiles_transforms_defaults_and_review_bindings_are_reported() { let previous = compiled(); let mut current = previous.clone(); let operation = &mut current.resources[0].operations[0]; - operation.representations[0] + operation.access_profiles[0] .transform_inventory .push("partial-string:suffix:4".into()); - let mut alternate = operation.representations[0].clone(); + let mut alternate = operation.access_profiles[0].clone(); alternate.id = "alternate".into(); - operation.representations.push(alternate); - operation.default_representation = "alternate".into(); + operation.access_profiles.push(alternate); + operation.default_access_profile = "alternate".into(); current .classification_review .as_mut() @@ -1122,8 +1134,8 @@ mod tests { let report = diff_registries(&previous, ¤t); for class in [ ChangeClass::TransformationChanged, - ChangeClass::RepresentationAdded, - ChangeClass::DefaultRepresentationChanged, + ChangeClass::AccessProfileAdded, + ChangeClass::DefaultAccessProfileChanged, ChangeClass::ClassificationReviewChanged, ] { assert!( @@ -1136,7 +1148,28 @@ mod tests { assert!(reverse .changes .iter() - .any(|change| change.class == ChangeClass::RepresentationRemoved)); + .any(|change| change.class == ChangeClass::AccessProfileRemoved)); + } + + #[test] + fn access_profile_authorization_changes_do_not_masquerade_as_classification_changes() { + let previous = compiled(); + let mut current = previous.clone(); + current.resources[0].operations[0].access_profiles[0].access = CompiledAccess::Protected { + scope: "registry:records:read".into(), + purpose: None, + row_binding: None, + }; + + let report = diff_registries(&previous, ¤t); + assert_eq!( + report + .changes + .iter() + .map(|change| (change.class, change.impact)) + .collect::>(), + [(ChangeClass::ScopeChanged, ChangeImpact::Narrowing)] + ); } #[test] @@ -1246,46 +1279,28 @@ mod tests { } #[test] - fn spatial_changes_are_explicitly_classified() { - let previous = compiled(); - let mut current = previous.clone(); - let classification = current.resources[0].properties[0].classification.clone(); - current.resources[0].primary_geometry = Some(crate::model::CompiledPrimaryGeometry { - name: "location".into(), - label: "Location".into(), - description: "Authoritative point".into(), - semantic_iri: "https://example.invalid/location".into(), - source_required: true, - crs: "http://www.opengis.net/def/crs/OGC/0/CRS84".into(), - longitude_column: "longitude".into(), - latitude_column: "latitude".into(), - classification, - }); - let operation = &mut current.resources[0].operations[0]; - operation.representations[0] - .selectable_properties - .push("location".into()); - operation.representations[0] - .projected_columns - .extend(["longitude".into(), "latitude".into()]); - operation.query.spatial_bbox = Some(crate::model::CompiledSpatialBboxQuery { - longitude_column: "longitude".into(), - latitude_column: "latitude".into(), - maximum_longitude_span_degrees: 10, - maximum_latitude_span_degrees: 10, - }); - + fn named_search_add_remove_and_span_changes_are_explicit() { + let current = compiled_spatial(); + let mut previous = current.clone(); + previous.resources[0].operations.clear(); let report = diff_registries(&previous, ¤t); - for class in [ - ChangeClass::GeometryAdded, - ChangeClass::DisclosureExpanded, - ChangeClass::SpatialQueryAdded, - ] { - assert!( - report.changes.iter().any(|change| change.class == class), - "missing {class:?}" - ); - } + assert_eq!( + report + .changes + .iter() + .map(|change| (change.class, change.impact)) + .collect::>(), + [(ChangeClass::OperationAdded, ChangeImpact::Widening)] + ); + let report = diff_registries(¤t, &previous); + assert_eq!( + report + .changes + .iter() + .map(|change| (change.class, change.impact)) + .collect::>(), + [(ChangeClass::OperationRemoved, ChangeImpact::Breaking)] + ); let mut expanded = current.clone(); expanded.resources[0].operations[0] @@ -1302,35 +1317,8 @@ mod tests { } #[test] - fn spatial_query_use_changes_do_not_masquerade_as_classification_changes() { - let previous = compiled(); - let mut added = previous.clone(); - let operation_identifier = added.resources[0].operations[0].identifier.clone(); - added.resources[0].operations[0].query.spatial_bbox = - Some(crate::model::CompiledSpatialBboxQuery { - longitude_column: "name".into(), - latitude_column: "name".into(), - maximum_longitude_span_degrees: 10, - maximum_latitude_span_degrees: 10, - }); - added.resources[0] - .column_accounting - .iter_mut() - .find(|account| account.column == "name") - .expect("published property column is accounted") - .uses - .push(crate::model::ColumnUse::SpatialBbox(operation_identifier)); - - let report = diff_registries(&previous, &added); - assert_eq!( - report - .changes - .iter() - .map(|change| (change.class, change.impact)) - .collect::>(), - [(ChangeClass::SpatialQueryAdded, ChangeImpact::Widening)] - ); - + fn spatial_query_changes_do_not_masquerade_as_classification_changes() { + let added = compiled_spatial(); let mut expanded = added.clone(); expanded.resources[0].operations[0] .query @@ -1364,15 +1352,5 @@ mod tests { .collect::>(), [(ChangeClass::SpatialQueryNarrowed, ChangeImpact::Narrowing)] ); - - let report = diff_registries(&added, &previous); - assert_eq!( - report - .changes - .iter() - .map(|change| (change.class, change.impact)) - .collect::>(), - [(ChangeClass::SpatialQueryRemoved, ChangeImpact::Breaking)] - ); } } diff --git a/crates/registry-relay-v2/src/fixture_contract.rs b/crates/registry-relay-v2/src/fixture_contract.rs index 9f2409d63..a4f22fa43 100644 --- a/crates/registry-relay-v2/src/fixture_contract.rs +++ b/crates/registry-relay-v2/src/fixture_contract.rs @@ -97,7 +97,7 @@ pub struct FixtureExpectation { #[serde(default)] pub geometry_type: Option, #[serde(default)] - pub representation_profile: Option, + pub format_profile: Option, } #[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] @@ -117,7 +117,7 @@ pub enum FixtureGeometryType { #[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] -pub enum FixtureRepresentationProfile { +pub enum FixtureFormatProfile { Rfc7946, JsonFg, } diff --git a/crates/registry-relay-v2/src/fixtures.rs b/crates/registry-relay-v2/src/fixtures.rs index 311dc5c17..10ff9096c 100644 --- a/crates/registry-relay-v2/src/fixtures.rs +++ b/crates/registry-relay-v2/src/fixtures.rs @@ -15,9 +15,9 @@ use tower::ServiceExt as _; use crate::auth::{FixturePrincipal, RelayAuthenticator}; pub use crate::fixture_contract::{ - parse_journey, FixtureAuthorization, FixtureError, FixtureExpectation, FixtureGeoJsonRoot, - FixtureGeometryType, FixtureJourney, FixtureMethod, FixtureRepresentationProfile, - FixtureRequest, FixtureStep, + parse_journey, FixtureAuthorization, FixtureError, FixtureExpectation, FixtureFormatProfile, + FixtureGeoJsonRoot, FixtureGeometryType, FixtureJourney, FixtureMethod, FixtureRequest, + FixtureStep, }; use crate::model::{CompiledAccess, CompiledRegistry, OperationKind}; @@ -144,18 +144,18 @@ pub fn compile_fixture_plan( ); } if let Some(operation) = operation { - let representation_identifier = step + let access_profile_identifier = step .request .query - .get("representation") + .get("accessProfile") .and_then(Value::as_str) - .unwrap_or(&operation.default_representation); + .unwrap_or(&operation.default_access_profile); let protected = operation - .representations + .access_profiles .iter() - .find(|representation| representation.id == representation_identifier) - .is_some_and(|representation| { - matches!(representation.access, CompiledAccess::Protected { .. }) + .find(|access_profile| access_profile.id == access_profile_identifier) + .is_some_and(|access_profile| { + matches!(access_profile.access, CompiledAccess::Protected { .. }) }); if protected && step.expect.status == 200 && step.authorization_fixture.is_none() { diagnostic( @@ -591,7 +591,7 @@ fn assert_expectations( if previous.is_none() || current != previous { mismatch( diagnostics, - "fixture.representation_mismatch", + "fixture.format_mismatch", &location, "JSON and JSON-LD Record equivalence", ); @@ -634,7 +634,7 @@ fn assert_geojson_expectations( let Some(document) = response.document else { if step.expect.geo_json_root.is_some() || step.expect.geometry_type.is_some() - || step.expect.representation_profile.is_some() + || step.expect.format_profile.is_some() { mismatch( diagnostics, @@ -681,7 +681,7 @@ fn assert_geojson_expectations( ); } } - let Some(profile) = step.expect.representation_profile else { + let Some(profile) = step.expect.format_profile else { return; }; if response @@ -692,16 +692,14 @@ fn assert_geojson_expectations( { mismatch( diagnostics, - "fixture.representation_profile_mismatch", + "fixture.format_profile_mismatch", location, "GeoJSON content type", ); } let (profile_uri, conformance) = match profile { - FixtureRepresentationProfile::Rfc7946 => { - ("http://www.opengis.net/def/profile/OGC/0/rfc7946", None) - } - FixtureRepresentationProfile::JsonFg => ( + FixtureFormatProfile::Rfc7946 => ("http://www.opengis.net/def/profile/OGC/0/rfc7946", None), + FixtureFormatProfile::JsonFg => ( "http://www.opengis.net/def/profile/OGC/0/jsonfg", Some([ "http://www.opengis.net/spec/json-fg-1/1.0/conf/core", @@ -718,7 +716,7 @@ fn assert_geojson_expectations( { mismatch( diagnostics, - "fixture.representation_profile_mismatch", + "fixture.format_profile_mismatch", location, "GeoJSON profile link", ); @@ -736,7 +734,7 @@ fn assert_geojson_expectations( if actual != Some(expected.into_iter().collect()) { mismatch( diagnostics, - "fixture.representation_profile_mismatch", + "fixture.format_profile_mismatch", location, "JSON-FG conformance", ); @@ -744,7 +742,7 @@ fn assert_geojson_expectations( } else if document.get("conformsTo").is_some() || document.get("featureType").is_some() { mismatch( diagnostics, - "fixture.representation_profile_mismatch", + "fixture.format_profile_mismatch", location, "RFC 7946 profile members", ); @@ -841,6 +839,10 @@ fn resolve_operation<'a>( request.method == FixtureMethod::Post && request.path == format!("/v2/resources/{}/lookups/{name}", resource.id) } + OperationKind::Search { name } => { + request.method == FixtureMethod::Get + && request.path == format!("/v2/resources/{}/searches/{name}", resource.id) + } }) }) } @@ -1225,7 +1227,7 @@ steps: status: 200 geoJsonRoot: feature geometryType: Point - representationProfile: jsonfg + formatProfile: jsonfg "#; let journey = parse_journey(yaml).expect("closed GeoJSON expectations parse"); assert_eq!( @@ -1237,8 +1239,8 @@ steps: Some(FixtureGeometryType::Point) ); assert_eq!( - journey.steps[0].expect.representation_profile, - Some(FixtureRepresentationProfile::JsonFg) + journey.steps[0].expect.format_profile, + Some(FixtureFormatProfile::JsonFg) ); assert!(parse_journey(&yaml.replace("jsonfg", "draft-profile")).is_err()); @@ -1258,7 +1260,7 @@ steps: status: 200 geoJsonRoot: feature geometryType: "null" - representationProfile: rfc7946 + formatProfile: rfc7946 "#, ) .expect("fixture parses"); diff --git a/crates/registry-relay-v2/src/format_capabilities.rs b/crates/registry-relay-v2/src/format_capabilities.rs new file mode 100644 index 000000000..9b4e43241 --- /dev/null +++ b/crates/registry-relay-v2/src/format_capabilities.rs @@ -0,0 +1,150 @@ +// SPDX-License-Identifier: Apache-2.0 +//! One derived account of the response wire formats a compiled access profile permits. +//! +//! Format availability is presentation-only. It is derived from the immutable +//! compiled resource and access profile and never adds a second authorization +//! or disclosure plane. + +use serde::{Deserialize, Serialize}; + +use crate::model::{CompiledAccessProfile, CompiledResource, FormatProfile}; + +pub const CRS84_URI: &str = "http://www.opengis.net/def/crs/OGC/0/CRS84"; +pub const RFC7946_PROFILE_URI: &str = "http://www.opengis.net/def/profile/OGC/0/rfc7946"; +pub const JSON_FG_PROFILE_URI: &str = "http://www.opengis.net/def/profile/OGC/0/jsonfg"; +pub const JSON_FG_CORE_CONFORMANCE: &str = "http://www.opengis.net/spec/json-fg-1/1.0/conf/core"; +pub const JSON_FG_TYPES_CONFORMANCE: &str = + "http://www.opengis.net/spec/json-fg-1/1.0/conf/types-schemas"; + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct WireFormatCapability { + pub id: WireFormatIdentifier, + pub media_type: String, + pub format_profiles: Vec, +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum WireFormatIdentifier { + Json, + JsonLd, + Geojson, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct FormatProfileCapability { + pub id: FormatProfileIdentifier, + pub uri: String, + pub crs: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub conforms_to: Vec, +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum FormatProfileIdentifier { + Rfc7946, + Jsonfg, +} + +#[must_use] +pub fn supports_geojson( + resource: &CompiledResource, + access_profile: &CompiledAccessProfile, +) -> bool { + resource.primary_geometry.as_ref().is_some_and(|geometry| { + access_profile + .selectable_properties + .iter() + .any(|property| property == &geometry.name) + }) +} + +#[must_use] +pub fn response_format_capabilities( + resource: &CompiledResource, + access_profile: &CompiledAccessProfile, +) -> Vec { + let mut formats = vec![ + WireFormatCapability { + id: WireFormatIdentifier::Json, + media_type: "application/json".into(), + format_profiles: Vec::new(), + }, + WireFormatCapability { + id: WireFormatIdentifier::JsonLd, + media_type: "application/ld+json".into(), + format_profiles: Vec::new(), + }, + ]; + if supports_geojson(resource, access_profile) { + formats.push(WireFormatCapability { + id: WireFormatIdentifier::Geojson, + media_type: "application/geo+json".into(), + format_profiles: vec![ + format_profile_capability(FormatProfile::Rfc7946), + format_profile_capability(FormatProfile::JsonFg), + ], + }); + } + formats +} + +#[must_use] +pub fn format_profile_capability(profile: FormatProfile) -> FormatProfileCapability { + match profile { + FormatProfile::Rfc7946 => FormatProfileCapability { + id: FormatProfileIdentifier::Rfc7946, + uri: RFC7946_PROFILE_URI.into(), + crs: CRS84_URI.into(), + conforms_to: Vec::new(), + }, + FormatProfile::JsonFg => FormatProfileCapability { + id: FormatProfileIdentifier::Jsonfg, + uri: JSON_FG_PROFILE_URI.into(), + crs: CRS84_URI.into(), + conforms_to: vec![ + JSON_FG_CORE_CONFORMANCE.into(), + JSON_FG_TYPES_CONFORMANCE.into(), + ], + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::compiler::{compile_contract_with_governed_files, tests as compiler_tests}; + use crate::model::CompileProfile; + + #[test] + fn geometry_disclosure_is_the_only_geojson_availability_gate() { + let contract = compiler_tests::spatial_contract(true); + let registry = compile_contract_with_governed_files( + &contract, + &[compiler_tests::spatial_observed_schema()], + CompileProfile::Production, + &compiler_tests::governed_files_for(&contract), + ) + .expect("spatial contract compiles"); + let resource = ®istry.resources[0]; + let access_profile = &resource.operations[0].access_profiles[0]; + assert_eq!( + response_format_capabilities(resource, access_profile).len(), + 3 + ); + + let mut hidden_geometry = access_profile.clone(); + let geometry = resource.primary_geometry.as_ref().expect("geometry"); + hidden_geometry + .selectable_properties + .retain(|property| property != &geometry.name); + let formats = response_format_capabilities(resource, &hidden_geometry); + assert_eq!(formats.len(), 2); + assert!(formats + .iter() + .all(|format| format.id != WireFormatIdentifier::Geojson)); + } +} diff --git a/crates/registry-relay-v2/src/identification.rs b/crates/registry-relay-v2/src/identification.rs index d284b41a2..60b0ec58e 100644 --- a/crates/registry-relay-v2/src/identification.rs +++ b/crates/registry-relay-v2/src/identification.rs @@ -17,14 +17,20 @@ use crate::contract::{ AccessRule, AuthorityRowBinding, ClassificationReviewDocument, GeneratedIdentificationBinding, Handling, IdentificationMethod, RegistryContract, ReviewStatus, RulePackBinding, }; +use crate::format_capabilities::{ + response_format_capabilities, FormatProfileIdentifier, WireFormatCapability, + WireFormatIdentifier, CRS84_URI, +}; use crate::model::{ - ColumnUse, CompiledAccess, CompiledOperation, CompiledRegistry, CompiledRepresentation, - CompiledResource, EffectiveClassification, ObservedColumn, ObservedSourceSchema, OperationKind, + CapabilityFamily, ColumnUse, CompiledAccess, CompiledAccessProfile, CompiledOperation, + CompiledRegistry, CompiledResource, CompiledTransform, ConsultationPattern, + EffectiveClassification, ObservedColumn, ObservedSourceSchema, OperationKind, + RowAuthoritySource, }; pub const IDENTIFICATION_REPORT_PATH: &str = "reports/identification-report.json"; pub const CLASSIFICATION_INVENTORY_REPORT_PATH: &str = "reports/classification-inventory.json"; -pub const REPRESENTATION_REPORT_PATH: &str = "reports/representation-report.json"; +pub const OPERATION_EXPLANATION_PATH: &str = "reports/operation-explanation.json"; pub const CONTEXTUAL_REVIEW_FINDINGS_PATH: &str = "reports/contextual-review-findings.json"; pub const CLASSIFICATION_REVIEW_STARTER_PATH: &str = "governance/classification-review-starter.yaml"; @@ -378,111 +384,839 @@ pub fn render_classification_inventory_report( #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] #[serde(deny_unknown_fields, rename_all = "camelCase")] -pub struct RepresentationReport { +pub struct OperationExplanation { pub api_version: String, pub kind: String, pub registry_identifier: String, + pub contract_revision: String, pub classification_inventory_digest: String, - pub resources: Vec, + pub operations: Vec, } #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] #[serde(deny_unknown_fields, rename_all = "camelCase")] -pub struct ResourceRepresentationReport { - pub resource: String, - pub source: String, - pub view: String, - pub operations: Vec, +pub struct OperationExplanationEntry { + pub resource_identifier: String, + pub operation_identifier: String, + pub family: CapabilityFamily, + pub pattern: ConsultationPattern, + pub operation_kind: String, + pub http: HttpOperationBinding, + pub query: QueryExplanation, + pub selection: SelectionExplanation, + pub access_profiles: Vec, } #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] #[serde(deny_unknown_fields, rename_all = "camelCase")] -pub struct OperationRepresentationReport { - pub operation: String, - pub operation_kind: String, - pub default_representation: String, - pub representations: Vec, +pub struct HttpOperationBinding { + pub method: HttpMethod, + pub path: String, +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "UPPERCASE")] +pub enum HttpMethod { + Get, + Post, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct QueryExplanation { + pub capabilities: Vec, + pub fixed_order_by: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct QueryCapabilityExplanation { + pub id: QueryCapabilityIdentifier, + pub availability: CapabilityAvailability, + pub reason: CapabilityReason, + pub required: bool, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub parameters: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub maximum_request_body_bytes: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub default_page_size: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub maximum_page_size: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub spatial: Option, +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum QueryCapabilityIdentifier { + ExactFilters, + Unfiltered, + Pagination, + ExactLookup, + PointBbox, + CallerSorting, +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum CapabilityAvailability { + Available, + Unavailable, +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum CapabilityReason { + DeclaredExactFilters, + NoDeclaredExactFilters, + OperationAllowsUnfiltered, + OperationRequiresDeclaredFilter, + PaginationConfigured, + PaginationNotApplicable, + ExactLookupOperation, + NotExactLookupOperation, + PointBboxSearchOperation, + NotPointBboxSearchOperation, + FixedOrderOnly, + NotApplicableToOperation, } #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] #[serde(deny_unknown_fields, rename_all = "camelCase")] -pub struct RepresentationBoundary { - pub representation: String, +pub struct SpatialQueryExplanation { + pub parameter: String, + pub crs: String, + pub predicate: String, + pub maximum_longitude_span_degrees: u16, + pub maximum_latitude_span_degrees: u16, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct SelectionExplanation { + pub access_profile_parameter: String, + pub fields_parameter: String, + pub format_profile_parameter: String, + pub default_access_profile: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct AccessProfileExplanation { + pub id: String, pub default: bool, - pub disclosure_profile: String, - pub processed_source_columns: Vec, - pub disclosed_properties: Vec, - pub processing_handling: Handling, - pub disclosure_handling: Handling, - pub transforms: Vec, + pub access: AccessPolicyExplanation, + pub processing: ProcessingExplanation, + pub disclosure: DisclosureExplanation, + pub transforms: Vec, + pub wire_formats: Vec, + pub cache: CacheExplanation, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "kebab-case")] +pub enum AccessPolicyExplanation { + Public, + Protected { + scope: String, + #[serde(skip_serializing_if = "Option::is_none")] + purpose: Option, + #[serde(skip_serializing_if = "Option::is_none")] + row_binding: Option, + }, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct PurposeExplanation { + pub claim: String, + pub allowed_value_count: usize, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct RowBindingExplanation { + pub authority_source: RowAuthorityExplanation, + pub source_column: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "kebab-case")] +pub enum RowAuthorityExplanation { + Principal, + Claim { claim: String }, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ProcessingExplanation { + pub source_columns: Vec, + pub handling: Handling, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct DisclosureExplanation { + pub profile_identifier: String, + pub properties: Vec, + pub handling: Handling, } -pub fn representation_report( +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "kebab-case")] +pub enum TransformExplanation { + PartialString { + property: String, + identifier: String, + reveal: crate::contract::PartialStringReveal, + characters: u16, + }, + DatePrecision { + property: String, + identifier: String, + source_type: crate::contract::DateInputType, + precision: crate::contract::DatePrecision, + }, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct CacheExplanation { + pub kind: CachePosture, +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum CachePosture { + PublicRevalidate, + NoStore, +} + +pub fn operation_explanation( registry: &CompiledRegistry, classification_inventory_digest: &str, -) -> Result { +) -> Result { require_inventory_digest(registry, classification_inventory_digest)?; - let mut resources = registry + let mut operations = registry .resources .iter() - .map(|resource| { - let mut operations = resource + .flat_map(|resource| { + resource .operations .iter() .map(|operation| { - let mut representations = operation - .representations + let mut access_profiles = operation + .access_profiles .iter() - .map(|representation| RepresentationBoundary { - representation: representation.id.clone(), - default: representation.id == operation.default_representation, - disclosure_profile: representation.disclosure_profile.clone(), - processed_source_columns: processed_columns(operation, representation), - disclosed_properties: sorted_unique( - representation.selectable_properties.iter().cloned(), - ), - processing_handling: representation.processing_handling, - disclosure_handling: representation.disclosure_handling, - transforms: sorted_unique( - representation.transform_inventory.iter().cloned(), - ), + .map(|access_profile| AccessProfileExplanation { + id: access_profile.id.clone(), + default: access_profile.id == operation.default_access_profile, + access: access_policy_explanation(&access_profile.access), + processing: ProcessingExplanation { + source_columns: processed_columns(operation, access_profile), + handling: access_profile.processing_handling, + }, + disclosure: DisclosureExplanation { + profile_identifier: access_profile.disclosure_profile.clone(), + properties: sorted_unique( + access_profile.selectable_properties.iter().cloned(), + ), + handling: access_profile.disclosure_handling, + }, + transforms: transform_explanations(resource, access_profile), + wire_formats: response_format_capabilities(resource, access_profile), + cache: CacheExplanation { + kind: if matches!(access_profile.access, CompiledAccess::Public) { + CachePosture::PublicRevalidate + } else { + CachePosture::NoStore + }, + }, }) .collect::>(); - representations - .sort_by(|left, right| left.representation.cmp(&right.representation)); - OperationRepresentationReport { - operation: operation.identifier.clone(), + access_profiles.sort_by(|left, right| left.id.cmp(&right.id)); + let (method, path) = operation_http_binding(resource, operation); + OperationExplanationEntry { + resource_identifier: resource.id.clone(), + operation_identifier: operation.identifier.clone(), + family: operation.family, + pattern: operation.pattern, operation_kind: operation_kind(&operation.kind), - default_representation: operation.default_representation.clone(), - representations, + http: HttpOperationBinding { method, path }, + query: query_explanation(operation), + selection: SelectionExplanation { + access_profile_parameter: "accessProfile".into(), + fields_parameter: "fields".into(), + format_profile_parameter: "formatProfile".into(), + default_access_profile: operation.default_access_profile.clone(), + }, + access_profiles, } }) - .collect::>(); - operations.sort_by(|left, right| left.operation.cmp(&right.operation)); - ResourceRepresentationReport { - resource: resource.id.clone(), - source: resource.source.clone(), - view: resource.view.clone(), - operations, - } + .collect::>() }) .collect::>(); - resources.sort_by(|left, right| left.resource.cmp(&right.resource)); - Ok(RepresentationReport { - api_version: "relay.registrystack.org/representation-report/v1".into(), - kind: "RepresentationReport".into(), + operations.sort_by(|left, right| { + left.resource_identifier + .cmp(&right.resource_identifier) + .then(left.operation_identifier.cmp(&right.operation_identifier)) + }); + Ok(OperationExplanation { + api_version: "relay.registrystack.org/operation-explanation/v1".into(), + kind: "OperationExplanation".into(), registry_identifier: registry.registry_identifier.clone(), + contract_revision: registry.contract_revision.clone(), classification_inventory_digest: classification_inventory_digest.into(), - resources, + operations, }) } -pub fn render_representation_report( - report: &RepresentationReport, +pub fn render_operation_explanation( + report: &OperationExplanation, ) -> Result, IdentificationError> { render_canonical(report) } +/// Render a compact, deterministic operator view without re-deriving any +/// contract semantics in the command-line adapter. +#[must_use] +pub fn render_operation_explanation_text(report: &OperationExplanation) -> String { + use std::fmt::Write as _; + + let mut output = String::new(); + let _ = writeln!(output, "Registry: {}", report.registry_identifier); + let _ = writeln!(output, "Contract revision: {}", report.contract_revision); + let mut current_resource = None; + for operation in &report.operations { + if current_resource != Some(operation.resource_identifier.as_str()) { + current_resource = Some(operation.resource_identifier.as_str()); + let _ = writeln!(output, "\nResource: {}", operation.resource_identifier); + } + let _ = writeln!( + output, + "\n Operation: {} {} {} consultation/{}", + operation.operation_identifier, + http_method(operation.http.method), + operation.http.path, + consultation_pattern_name(operation.pattern), + ); + let _ = writeln!( + output, + " selection: access-profile={}; fields={}; format-profile={}", + operation.selection.access_profile_parameter, + operation.selection.fields_parameter, + operation.selection.format_profile_parameter, + ); + let _ = writeln!( + output, + " default access profile: {}", + operation.selection.default_access_profile + ); + let _ = writeln!( + output, + " fixed order: {}", + comma_list(&operation.query.fixed_order_by) + ); + let _ = writeln!(output, " query capabilities:"); + for capability in &operation.query.capabilities { + let _ = writeln!( + output, + " {}: {} ({}){}", + query_capability_name(capability.id), + availability_name(capability.availability), + capability_reason_name(capability.reason), + if capability.required { + "; required" + } else { + "" + }, + ); + if !capability.parameters.is_empty() { + let _ = writeln!( + output, + " parameters: {}", + comma_list(&capability.parameters) + ); + } + if let Some(maximum) = capability.maximum_request_body_bytes { + let _ = writeln!(output, " maximum request bytes: {maximum}"); + } + if let (Some(default), Some(maximum)) = + (capability.default_page_size, capability.maximum_page_size) + { + let _ = writeln!( + output, + " page size: default={default}; maximum={maximum}" + ); + } + if let Some(spatial) = &capability.spatial { + let _ = writeln!( + output, + " spatial: parameter={}; crs={}; predicate={}; max-longitude-span={}; max-latitude-span={}", + spatial.parameter, + spatial.crs, + spatial.predicate, + spatial.maximum_longitude_span_degrees, + spatial.maximum_latitude_span_degrees, + ); + } + } + for access_profile in &operation.access_profiles { + let _ = writeln!( + output, + " access profile: {}{}", + access_profile.id, + if access_profile.default { + " (default)" + } else { + "" + } + ); + match &access_profile.access { + AccessPolicyExplanation::Public => { + let _ = writeln!(output, " access: public"); + } + AccessPolicyExplanation::Protected { + scope, + purpose, + row_binding, + } => { + let _ = writeln!(output, " access: protected; scope={scope}"); + if let Some(purpose) = purpose { + let _ = writeln!( + output, + " purpose: claim={}; allowed-value-count={}", + purpose.claim, purpose.allowed_value_count + ); + } + if let Some(row_binding) = row_binding { + let authority = match &row_binding.authority_source { + RowAuthorityExplanation::Principal => "principal".to_owned(), + RowAuthorityExplanation::Claim { claim } => { + format!("claim:{claim}") + } + }; + let _ = writeln!( + output, + " row binding: authority={authority}; source-column={}", + row_binding.source_column + ); + } + } + } + let _ = writeln!( + output, + " processing: {}; columns={}", + handling_name(access_profile.processing.handling), + comma_list(&access_profile.processing.source_columns), + ); + let _ = writeln!( + output, + " disclosure: {}; profile={}; properties={}", + handling_name(access_profile.disclosure.handling), + access_profile.disclosure.profile_identifier, + comma_list(&access_profile.disclosure.properties), + ); + if access_profile.transforms.is_empty() { + let _ = writeln!(output, " transforms: none"); + } else { + let _ = writeln!(output, " transforms:"); + for transform in &access_profile.transforms { + match transform { + TransformExplanation::PartialString { + property, + identifier, + reveal, + characters, + } => { + let reveal = match reveal { + crate::contract::PartialStringReveal::Prefix => "prefix", + crate::contract::PartialStringReveal::Suffix => "suffix", + }; + let _ = writeln!( + output, + " {property}: partial-string; id={identifier}; reveal={reveal}; characters={characters}" + ); + } + TransformExplanation::DatePrecision { + property, + identifier, + source_type, + precision, + } => { + let source_type = match source_type { + crate::contract::DateInputType::Date => "date", + crate::contract::DateInputType::DateTime => "date-time", + }; + let precision = match precision { + crate::contract::DatePrecision::Year => "year", + crate::contract::DatePrecision::YearMonth => "year-month", + }; + let _ = writeln!( + output, + " {property}: date-precision; id={identifier}; source-type={source_type}; precision={precision}" + ); + } + } + } + } + let _ = writeln!(output, " wire formats:"); + for format in &access_profile.wire_formats { + let profiles = format + .format_profiles + .iter() + .map(|profile| format_profile_name(profile.id)) + .collect::>(); + let profile_suffix = if profiles.is_empty() { + String::new() + } else { + format!("; format-profiles={}", profiles.join(", ")) + }; + let _ = writeln!( + output, + " {}: {}{}", + wire_format_name(format.id), + format.media_type, + profile_suffix + ); + } + let _ = writeln!( + output, + " cache: {}", + match access_profile.cache.kind { + CachePosture::PublicRevalidate => "public-revalidate", + CachePosture::NoStore => "no-store", + } + ); + } + } + output +} + +fn operation_http_binding( + resource: &CompiledResource, + operation: &CompiledOperation, +) -> (HttpMethod, String) { + match &operation.kind { + OperationKind::List => ( + HttpMethod::Get, + format!("/v2/resources/{}/records", resource.id), + ), + OperationKind::Read => ( + HttpMethod::Get, + format!("/v2/resources/{}/records/{{recordIdentifier}}", resource.id), + ), + OperationKind::Lookup { name } => ( + HttpMethod::Post, + format!("/v2/resources/{}/lookups/{name}", resource.id), + ), + OperationKind::Search { name } => ( + HttpMethod::Get, + format!("/v2/resources/{}/searches/{name}", resource.id), + ), + } +} + +fn query_explanation(operation: &CompiledOperation) -> QueryExplanation { + let mut filters = operation + .query + .filters + .iter() + .map(|filter| filter.parameter.clone()) + .collect::>(); + filters.sort(); + let exact_filters_available = !filters.is_empty(); + let unfiltered_applicable = matches!(operation.kind, OperationKind::List); + let pagination = operation.query.pagination.as_ref(); + let lookup = matches!(operation.kind, OperationKind::Lookup { .. }); + let mut selectors = operation + .query + .selectors + .iter() + .map(|selector| selector.name.clone()) + .collect::>(); + selectors.sort(); + let spatial = operation + .query + .spatial_bbox + .as_ref() + .map(|bbox| SpatialQueryExplanation { + parameter: "bbox".into(), + crs: CRS84_URI.into(), + predicate: "inclusive-point-within-bbox".into(), + maximum_longitude_span_degrees: bbox.maximum_longitude_span_degrees, + maximum_latitude_span_degrees: bbox.maximum_latitude_span_degrees, + }); + let capabilities = vec![ + QueryCapabilityExplanation { + id: QueryCapabilityIdentifier::ExactFilters, + availability: available(exact_filters_available), + reason: if exact_filters_available { + CapabilityReason::DeclaredExactFilters + } else { + CapabilityReason::NoDeclaredExactFilters + }, + required: exact_filters_available && !operation.query.allow_unfiltered, + parameters: filters, + maximum_request_body_bytes: None, + default_page_size: None, + maximum_page_size: None, + spatial: None, + }, + QueryCapabilityExplanation { + id: QueryCapabilityIdentifier::Unfiltered, + availability: available(unfiltered_applicable && operation.query.allow_unfiltered), + reason: if !unfiltered_applicable { + CapabilityReason::NotApplicableToOperation + } else if operation.query.allow_unfiltered { + CapabilityReason::OperationAllowsUnfiltered + } else { + CapabilityReason::OperationRequiresDeclaredFilter + }, + required: false, + parameters: Vec::new(), + maximum_request_body_bytes: None, + default_page_size: None, + maximum_page_size: None, + spatial: None, + }, + QueryCapabilityExplanation { + id: QueryCapabilityIdentifier::Pagination, + availability: available(pagination.is_some()), + reason: if pagination.is_some() { + CapabilityReason::PaginationConfigured + } else { + CapabilityReason::PaginationNotApplicable + }, + required: false, + parameters: pagination + .map(|_| vec!["pageSize".into(), "cursor".into()]) + .unwrap_or_default(), + maximum_request_body_bytes: None, + default_page_size: pagination.map(|value| value.default_page_size), + maximum_page_size: pagination.map(|value| value.maximum_page_size), + spatial: None, + }, + QueryCapabilityExplanation { + id: QueryCapabilityIdentifier::ExactLookup, + availability: available(lookup), + reason: if lookup { + CapabilityReason::ExactLookupOperation + } else { + CapabilityReason::NotExactLookupOperation + }, + required: lookup, + parameters: selectors, + maximum_request_body_bytes: operation.query.maximum_request_body_bytes, + default_page_size: None, + maximum_page_size: None, + spatial: None, + }, + QueryCapabilityExplanation { + id: QueryCapabilityIdentifier::PointBbox, + availability: available(spatial.is_some()), + reason: if spatial.is_some() { + CapabilityReason::PointBboxSearchOperation + } else { + CapabilityReason::NotPointBboxSearchOperation + }, + required: spatial.is_some(), + parameters: spatial + .as_ref() + .map(|_| vec!["bbox".into()]) + .unwrap_or_default(), + maximum_request_body_bytes: None, + default_page_size: None, + maximum_page_size: None, + spatial, + }, + QueryCapabilityExplanation { + id: QueryCapabilityIdentifier::CallerSorting, + availability: CapabilityAvailability::Unavailable, + reason: CapabilityReason::FixedOrderOnly, + required: false, + parameters: Vec::new(), + maximum_request_body_bytes: None, + default_page_size: None, + maximum_page_size: None, + spatial: None, + }, + ]; + QueryExplanation { + capabilities, + fixed_order_by: operation.query.order_by.clone(), + } +} + +fn available(value: bool) -> CapabilityAvailability { + if value { + CapabilityAvailability::Available + } else { + CapabilityAvailability::Unavailable + } +} + +fn access_policy_explanation(access: &CompiledAccess) -> AccessPolicyExplanation { + match access { + CompiledAccess::Public => AccessPolicyExplanation::Public, + CompiledAccess::Protected { + scope, + purpose, + row_binding, + } => AccessPolicyExplanation::Protected { + scope: scope.clone(), + purpose: purpose.as_ref().map(|purpose| PurposeExplanation { + claim: purpose.claim.clone(), + allowed_value_count: purpose.allowed.len(), + }), + row_binding: row_binding.as_ref().map(|binding| RowBindingExplanation { + authority_source: match &binding.source { + RowAuthoritySource::Principal => RowAuthorityExplanation::Principal, + RowAuthoritySource::Claim(claim) => RowAuthorityExplanation::Claim { + claim: claim.clone(), + }, + }, + source_column: binding.source_column.clone(), + }), + }, + } +} + +fn transform_explanations( + resource: &CompiledResource, + access_profile: &CompiledAccessProfile, +) -> Vec { + let selectable = access_profile + .selectable_properties + .iter() + .map(String::as_str) + .collect::>(); + let mut transforms = resource + .properties + .iter() + .filter(|property| selectable.contains(property.name.as_str())) + .filter_map(|property| { + property + .transform + .as_ref() + .map(|transform| match transform { + CompiledTransform::PartialString { + identifier, + reveal, + characters, + } => TransformExplanation::PartialString { + property: property.name.clone(), + identifier: identifier.clone(), + reveal: *reveal, + characters: *characters, + }, + CompiledTransform::DatePrecision { + identifier, + source_type, + precision, + } => TransformExplanation::DatePrecision { + property: property.name.clone(), + identifier: identifier.clone(), + source_type: *source_type, + precision: *precision, + }, + }) + }) + .collect::>(); + transforms.sort_by(|left, right| transform_property(left).cmp(transform_property(right))); + transforms +} + +fn transform_property(transform: &TransformExplanation) -> &str { + match transform { + TransformExplanation::PartialString { property, .. } + | TransformExplanation::DatePrecision { property, .. } => property, + } +} + +fn http_method(method: HttpMethod) -> &'static str { + match method { + HttpMethod::Get => "GET", + HttpMethod::Post => "POST", + } +} + +fn consultation_pattern_name(pattern: ConsultationPattern) -> &'static str { + match pattern { + ConsultationPattern::List => "list", + ConsultationPattern::Retrieve => "retrieve", + ConsultationPattern::Search => "search", + } +} + +fn query_capability_name(capability: QueryCapabilityIdentifier) -> &'static str { + match capability { + QueryCapabilityIdentifier::ExactFilters => "exact-filters", + QueryCapabilityIdentifier::Unfiltered => "unfiltered", + QueryCapabilityIdentifier::Pagination => "pagination", + QueryCapabilityIdentifier::ExactLookup => "exact-lookup", + QueryCapabilityIdentifier::PointBbox => "point-bbox", + QueryCapabilityIdentifier::CallerSorting => "caller-sorting", + } +} + +fn availability_name(availability: CapabilityAvailability) -> &'static str { + match availability { + CapabilityAvailability::Available => "available", + CapabilityAvailability::Unavailable => "unavailable", + } +} + +fn capability_reason_name(reason: CapabilityReason) -> &'static str { + match reason { + CapabilityReason::DeclaredExactFilters => "declared-exact-filters", + CapabilityReason::NoDeclaredExactFilters => "no-declared-exact-filters", + CapabilityReason::OperationAllowsUnfiltered => "operation-allows-unfiltered", + CapabilityReason::OperationRequiresDeclaredFilter => "operation-requires-declared-filter", + CapabilityReason::PaginationConfigured => "pagination-configured", + CapabilityReason::PaginationNotApplicable => "pagination-not-applicable", + CapabilityReason::ExactLookupOperation => "exact-lookup-operation", + CapabilityReason::NotExactLookupOperation => "not-exact-lookup-operation", + CapabilityReason::PointBboxSearchOperation => "point-bbox-search-operation", + CapabilityReason::NotPointBboxSearchOperation => "not-point-bbox-search-operation", + CapabilityReason::FixedOrderOnly => "fixed-order-only", + CapabilityReason::NotApplicableToOperation => "not-applicable-to-operation", + } +} + +fn handling_name(handling: Handling) -> &'static str { + match handling { + Handling::Public => "public", + Handling::Internal => "internal", + Handling::Confidential => "confidential", + Handling::Restricted => "restricted", + } +} + +fn comma_list(values: &[String]) -> String { + if values.is_empty() { + "none".into() + } else { + values.join(", ") + } +} + +fn wire_format_name(format: WireFormatIdentifier) -> &'static str { + match format { + WireFormatIdentifier::Json => "json", + WireFormatIdentifier::JsonLd => "json-ld", + WireFormatIdentifier::Geojson => "geojson", + } +} + +fn format_profile_name(profile: FormatProfileIdentifier) -> &'static str { + match profile { + FormatProfileIdentifier::Rfc7946 => "rfc7946", + FormatProfileIdentifier::Jsonfg => "jsonfg", + } +} + #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] #[serde(deny_unknown_fields, rename_all = "camelCase")] pub struct ContextualReviewFindings { @@ -500,7 +1234,7 @@ pub struct ContextualReviewFinding { pub status: ContextualFindingStatus, pub resource: String, pub operation: Option, - pub representation: Option, + pub access_profile: Option, pub properties: Vec, pub source_columns: Vec, pub message: String, @@ -513,7 +1247,7 @@ pub enum ContextualFindingStatus { } /// Generate fixed contextual prompts. Findings never grant access, select a -/// representation, or alter a compiled handling floor. +/// access profile, or alter a compiled handling floor. pub fn contextual_review_findings( registry: &CompiledRegistry, classification_inventory_digest: &str, @@ -653,14 +1387,14 @@ pub fn contextual_review_findings( } for operation in &resource.operations { - for representation in &operation.representations { + for access_profile in &operation.access_profiles { let restrictive_selectors = operation .query .selectors .iter() .filter(|selector| { column_handling(resource, &selector.source_column) - .is_some_and(|handling| handling > representation.disclosure_handling) + .is_some_and(|handling| handling > access_profile.disclosure_handling) }) .collect::>(); if !restrictive_selectors.is_empty() { @@ -669,8 +1403,8 @@ pub fn contextual_review_findings( "classification.context.selector_more_restrictive_than_disclosure", resource, Some(&operation.identifier), - Some(&representation.id), - representation.selectable_properties.iter().cloned(), + Some(&access_profile.id), + access_profile.selectable_properties.iter().cloned(), restrictive_selectors .iter() .map(|selector| selector.source_column.clone()), @@ -678,22 +1412,22 @@ pub fn contextual_review_findings( ); } if matches!(operation.kind, OperationKind::List) - && representation.disclosure_handling >= Handling::Confidential + && access_profile.disclosure_handling >= Handling::Confidential { push_finding( &mut findings, "classification.context.nonpublic_list_disclosure", resource, Some(&operation.identifier), - Some(&representation.id), - representation.selectable_properties.iter().cloned(), + Some(&access_profile.id), + access_profile.selectable_properties.iter().cloned(), std::iter::empty(), - "confidential or restricted data appears in a list representation", + "confidential or restricted data appears in a list access profile", ); } - if matches!(representation.access, CompiledAccess::Public) { - let disclosed_columns = disclosed_source_columns(resource, representation); - let hidden_nonpublic = processed_columns(operation, representation) + if matches!(access_profile.access, CompiledAccess::Public) { + let disclosed_columns = disclosed_source_columns(resource, access_profile); + let hidden_nonpublic = processed_columns(operation, access_profile) .into_iter() .filter(|column| !disclosed_columns.contains(column)) .filter(|column| { @@ -707,10 +1441,10 @@ pub fn contextual_review_findings( "classification.context.public_processes_hidden_nonpublic", resource, Some(&operation.identifier), - Some(&representation.id), - representation.selectable_properties.iter().cloned(), + Some(&access_profile.id), + access_profile.selectable_properties.iter().cloned(), hidden_nonpublic, - "a public representation processes hidden non-public source columns", + "a public access profile processes hidden non-public source columns", ); } } @@ -721,7 +1455,7 @@ pub fn contextual_review_findings( left.resource .cmp(&right.resource) .then(left.operation.cmp(&right.operation)) - .then(left.representation.cmp(&right.representation)) + .then(left.access_profile.cmp(&right.access_profile)) .then(left.code.cmp(&right.code)) .then(left.properties.cmp(&right.properties)) .then(left.source_columns.cmp(&right.source_columns)) @@ -1213,13 +1947,13 @@ fn authored_hints(contract: &RegistryContract) -> BTreeMap<(String, String, Stri ); } } - for (_, representation) in operation.representations.iter() { - add_access_roles(&mut hints, source, view, &representation.access); + for (_, access_profile) in operation.access_profiles.iter() { + add_access_roles(&mut hints, source, view, &access_profile.access); } } if let Some(operation) = &resource.operations.read { - for (_, representation) in operation.representations.iter() { - add_access_roles(&mut hints, source, view, &representation.access); + for (_, access_profile) in operation.access_profiles.iter() { + add_access_roles(&mut hints, source, view, &access_profile.access); } } for lookup in &resource.operations.lookups { @@ -1235,8 +1969,24 @@ fn authored_hints(contract: &RegistryContract) -> BTreeMap<(String, String, Stri add_codelist(&mut hints, source, view, &selector.source_column); } } - for (_, representation) in lookup.representations.iter() { - add_access_roles(&mut hints, source, view, &representation.access); + for (_, access_profile) in lookup.access_profiles.iter() { + add_access_roles(&mut hints, source, view, &access_profile.access); + } + } + for search in &resource.operations.searches { + for property_name in &search.order_by { + if let Some(property) = resource.properties.get(property_name) { + add_role( + &mut hints, + source, + view, + &property.source_column, + AuthoredRole::Order, + ); + } + } + for (_, access_profile) in search.access_profiles.iter() { + add_access_roles(&mut hints, source, view, &access_profile.access); } } } @@ -1526,14 +2276,15 @@ fn operation_kind(kind: &OperationKind) -> String { OperationKind::List => "list".into(), OperationKind::Read => "read".into(), OperationKind::Lookup { name } => format!("lookup:{name}"), + OperationKind::Search { name } => format!("search:{name}"), } } fn processed_columns( operation: &CompiledOperation, - representation: &CompiledRepresentation, + access_profile: &CompiledAccessProfile, ) -> Vec { - let mut columns = representation + let mut columns = access_profile .projected_columns .iter() .cloned() @@ -1545,6 +2296,10 @@ fn processed_columns( .iter() .map(|filter| filter.source_column.clone()), ); + if let Some(spatial) = &operation.query.spatial_bbox { + columns.insert(spatial.longitude_column.clone()); + columns.insert(spatial.latitude_column.clone()); + } columns.extend(operation.query.order_by.iter().cloned()); columns.extend( operation @@ -1556,7 +2311,7 @@ fn processed_columns( if let CompiledAccess::Protected { row_binding: Some(binding), .. - } = &representation.access + } = &access_profile.access { columns.insert(binding.source_column.clone()); } @@ -1565,7 +2320,7 @@ fn processed_columns( fn disclosed_source_columns( resource: &CompiledResource, - representation: &CompiledRepresentation, + access_profile: &CompiledAccessProfile, ) -> BTreeSet { let mut columns = [ &resource.record_context.record_identifier_column, @@ -1576,7 +2331,7 @@ fn disclosed_source_columns( .into_iter() .cloned() .collect::>(); - for name in &representation.selectable_properties { + for name in &access_profile.selectable_properties { if let Some(property) = resource .properties .iter() @@ -1584,6 +2339,14 @@ fn disclosed_source_columns( { columns.insert(property.source_column.clone()); } + if let Some(geometry) = resource + .primary_geometry + .as_ref() + .filter(|geometry| geometry.name == *name) + { + columns.insert(geometry.longitude_column.clone()); + columns.insert(geometry.latitude_column.clone()); + } } columns } @@ -1606,7 +2369,7 @@ fn push_finding( code: &str, resource: &CompiledResource, operation: Option<&str>, - representation: Option<&str>, + access_profile: Option<&str>, properties: I, source_columns: J, message: &str, @@ -1619,7 +2382,7 @@ fn push_finding( status: ContextualFindingStatus::ReviewRequired, resource: resource.id.clone(), operation: operation.map(str::to_owned), - representation: representation.map(str::to_owned), + access_profile: access_profile.map(str::to_owned), properties: sorted_unique(properties.into_iter()), source_columns: sorted_unique(source_columns.into_iter()), message: message.into(), @@ -1720,6 +2483,11 @@ fn push_review_diagnostic( #[cfg(test)] mod tests { use super::*; + use crate::compiler::{ + classification_inventory_digest, compile_contract_with_governed_files, + tests as compiler_tests, + }; + use crate::model::{CompileProfile, CompiledPurpose, CompiledRowBinding}; #[test] fn pack_tampering_is_refused_before_parsing() { @@ -1738,4 +2506,137 @@ mod tests { Err(IdentificationError::PackDigestMismatch) ); } + + #[test] + fn operation_explanation_is_canonical_value_free_and_complete_for_spatial_search() { + let contract = compiler_tests::spatial_contract(true); + let mut registry = compile_contract_with_governed_files( + &contract, + &[compiler_tests::spatial_observed_schema()], + CompileProfile::Production, + &compiler_tests::governed_files_for(&contract), + ) + .expect("spatial contract compiles"); + registry.resources[0].properties[0].transform = Some(CompiledTransform::PartialString { + identifier: "partial-string:suffix:2".into(), + reveal: crate::contract::PartialStringReveal::Suffix, + characters: 2, + }); + let canary = "PURPOSE_VALUE_CANARY_58a4c9"; + registry.resources[0].operations[0].access_profiles[0].access = CompiledAccess::Protected { + scope: "registry:records:search".into(), + purpose: Some(CompiledPurpose { + claim: "purpose".into(), + allowed: vec![canary.into()], + }), + row_binding: Some(CompiledRowBinding { + source: RowAuthoritySource::Claim("authority".into()), + source_column: "name".into(), + }), + }; + let mut hidden_geometry = registry.resources[0].operations[0].access_profiles[0].clone(); + hidden_geometry.id = "hidden-geometry".into(); + hidden_geometry + .selectable_properties + .retain(|property| property != "location"); + hidden_geometry + .projected_columns + .retain(|column| !matches!(column.as_str(), "longitude" | "latitude")); + registry.resources[0].operations[0] + .access_profiles + .push(hidden_geometry); + let digest = classification_inventory_digest(®istry).expect("inventory digest"); + let explanation = operation_explanation(®istry, &digest).expect("explanation"); + assert_eq!( + explanation.api_version, + "relay.registrystack.org/operation-explanation/v1" + ); + assert_eq!(explanation.kind, "OperationExplanation"); + let operation = &explanation.operations[0]; + assert_eq!(operation.operation_kind, "search:within-bbox"); + assert_eq!( + operation.http.path, + "/v2/resources/record/searches/within-bbox" + ); + let bbox = operation + .query + .capabilities + .iter() + .find(|capability| capability.id == QueryCapabilityIdentifier::PointBbox) + .expect("bbox capability"); + assert_eq!(bbox.availability, CapabilityAvailability::Available); + assert_eq!(bbox.reason, CapabilityReason::PointBboxSearchOperation); + assert!(bbox.required); + let caller_sorting = operation + .query + .capabilities + .iter() + .find(|capability| capability.id == QueryCapabilityIdentifier::CallerSorting) + .expect("sorting capability"); + assert_eq!( + caller_sorting.availability, + CapabilityAvailability::Unavailable + ); + assert_eq!(caller_sorting.reason, CapabilityReason::FixedOrderOnly); + + let access_profile = operation + .access_profiles + .iter() + .find(|profile| profile.id == "public") + .expect("public access profile"); + assert_eq!(access_profile.wire_formats.len(), 3); + assert!(matches!( + &access_profile.access, + AccessPolicyExplanation::Protected { + purpose: Some(PurposeExplanation { + allowed_value_count: 1, + .. + }), + row_binding: Some(_), + .. + } + )); + assert!(matches!( + access_profile.transforms.as_slice(), + [TransformExplanation::PartialString { + property, + characters: 2, + .. + }] if property == "name" + )); + let hidden_geometry = operation + .access_profiles + .iter() + .find(|profile| profile.id == "hidden-geometry") + .expect("hidden-geometry access profile"); + assert!(hidden_geometry + .processing + .source_columns + .contains(&"longitude".into())); + assert!(hidden_geometry + .processing + .source_columns + .contains(&"latitude".into())); + assert_eq!(hidden_geometry.wire_formats.len(), 2); + assert!(hidden_geometry + .wire_formats + .iter() + .all(|format| format.id != WireFormatIdentifier::Geojson)); + + let first = render_operation_explanation(&explanation).expect("canonical bytes"); + let second = render_operation_explanation(&explanation).expect("canonical bytes again"); + assert_eq!(first, second); + let encoded = String::from_utf8(first).expect("UTF-8 JSON"); + assert!(!encoded.contains(canary)); + assert!(encoded.contains("allowedValueCount")); + let mut unknown = serde_json::to_value(&explanation).expect("explanation serializes"); + unknown["operations"][0]["selection"]["unexpected"] = serde_json::json!(true); + assert!(serde_json::from_value::(unknown).is_err()); + let text = render_operation_explanation_text(&explanation); + assert!(text.contains("Resource: record")); + assert!(text.contains("Operation: record.search.within-bbox")); + assert!(text.contains("max-longitude-span=10")); + assert!(text.contains("format-profiles=rfc7946, jsonfg")); + assert!(!text.contains(canary)); + } } diff --git a/crates/registry-relay-v2/src/lib.rs b/crates/registry-relay-v2/src/lib.rs index a5bf2f5f3..ac5119a2c 100644 --- a/crates/registry-relay-v2/src/lib.rs +++ b/crates/registry-relay-v2/src/lib.rs @@ -12,6 +12,7 @@ pub mod diff; pub mod fixture_contract; #[cfg(feature = "tooling")] pub mod fixtures; +pub mod format_capabilities; pub mod identification; pub mod model; pub mod package; diff --git a/crates/registry-relay-v2/src/model.rs b/crates/registry-relay-v2/src/model.rs index 39328ff0c..8005c5eee 100644 --- a/crates/registry-relay-v2/src/model.rs +++ b/crates/registry-relay-v2/src/model.rs @@ -264,14 +264,14 @@ pub struct CompiledOperation { pub family: CapabilityFamily, pub pattern: ConsultationPattern, pub kind: OperationKind, - pub default_representation: String, - pub representations: Vec, + pub default_access_profile: String, + pub access_profiles: Vec, pub query: QueryPlan, } #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] -pub struct CompiledRepresentation { +pub struct CompiledAccessProfile { pub id: String, pub access: CompiledAccess, pub disclosure_profile: String, @@ -287,7 +287,7 @@ pub struct CompiledRepresentation { #[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)] #[serde(rename_all = "kebab-case")] -pub enum RepresentationProfile { +pub enum FormatProfile { Rfc7946, JsonFg, } @@ -312,6 +312,7 @@ pub enum OperationKind { List, Read, Lookup { name: String }, + Search { name: String }, } #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] diff --git a/crates/registry-relay-v2/src/package.rs b/crates/registry-relay-v2/src/package.rs index 9999f22cb..f00c1eb47 100644 --- a/crates/registry-relay-v2/src/package.rs +++ b/crates/registry-relay-v2/src/package.rs @@ -50,7 +50,7 @@ pub struct PackageArtifact { pub media_type: String, pub visibility: Visibility, pub operation_identifier: Option, - pub representation_identifier: Option, + pub access_profile_identifier: Option, pub sha256: String, } @@ -165,7 +165,7 @@ pub fn build_package( media_type: artifact.media_type.clone(), visibility: artifact.visibility, operation_identifier: artifact.operation_identifier.clone(), - representation_identifier: artifact.representation_identifier.clone(), + access_profile_identifier: artifact.access_profile_identifier.clone(), sha256: artifact.sha256.clone(), }) .collect::>(); @@ -265,7 +265,7 @@ fn validate_build_inputs( .collect::, _>>()?; verify_compiled_derivation(contract, compiled, governed, &observed)?; verify_artifact_derivation(compiled, artifacts)?; - let expected_operation_representations = operation_representation_pairs(compiled); + let expected_operation_access_profiles = operation_access_profile_pairs(compiled); let mut artifact_ids = BTreeSet::new(); let mut artifact_paths = BTreeSet::new(); for artifact in &artifacts.artifacts { @@ -276,17 +276,17 @@ fn validate_build_inputs( || artifact .operation_identifier .as_deref() - .zip(artifact.representation_identifier.as_deref()) - .is_some_and(|pair| !expected_operation_representations.contains(&pair)) + .zip(artifact.access_profile_identifier.as_deref()) + .is_some_and(|pair| !expected_operation_access_profiles.contains(&pair)) || artifact.operation_identifier.is_some() - != artifact.representation_identifier.is_some() + != artifact.access_profile_identifier.is_some() { return Err(PackageError::Verification); } } if !valid_operation_artifact_bindings( &artifacts.operation_bindings, - &expected_operation_representations, + &expected_operation_access_profiles, &artifact_paths, ) { return Err(PackageError::Verification); @@ -529,7 +529,7 @@ pub fn load_package(package_path: &Path) -> Result Result Result Result { fn valid_operation_artifact_bindings( bindings: &[OperationArtifactBindings], - expected_operation_representations: &BTreeSet<(&str, &str)>, + expected_operation_access_profiles: &BTreeSet<(&str, &str)>, artifact_paths: &BTreeSet<&str>, ) -> bool { - let mut bound_operation_representations = BTreeSet::new(); + let mut bound_operation_access_profiles = BTreeSet::new(); for binding in bindings { let pair = ( binding.operation_identifier.as_str(), - binding.representation_identifier.as_str(), + binding.access_profile_identifier.as_str(), ); - if !expected_operation_representations.contains(&pair) - || !bound_operation_representations.insert(pair) + if !expected_operation_access_profiles.contains(&pair) + || !bound_operation_access_profiles.insert(pair) || [ binding.vocabulary_path.as_str(), binding.context_path.as_str(), - binding.representation_schema_path.as_str(), - binding.representation_shacl_path.as_str(), + binding.access_profile_schema_path.as_str(), + binding.access_profile_shacl_path.as_str(), binding.classification_path.as_str(), binding.processing_path.as_str(), ] @@ -645,19 +645,19 @@ fn valid_operation_artifact_bindings( return false; } } - bound_operation_representations == *expected_operation_representations + bound_operation_access_profiles == *expected_operation_access_profiles } -fn operation_representation_pairs(registry: &CompiledRegistry) -> BTreeSet<(&str, &str)> { +fn operation_access_profile_pairs(registry: &CompiledRegistry) -> BTreeSet<(&str, &str)> { registry .resources .iter() .flat_map(|resource| resource.operations.iter()) .flat_map(|operation| { operation - .representations + .access_profiles .iter() - .map(|representation| (operation.identifier.as_str(), representation.id.as_str())) + .map(|access_profile| (operation.identifier.as_str(), access_profile.id.as_str())) }) .collect() } @@ -1008,11 +1008,11 @@ mod tests { } #[test] - fn multi_representation_package_bindings_are_exactly_closed() { + fn multi_access_profile_package_bindings_are_exactly_closed() { let yaml = crate::compiler::tests::valid_contract() .replace( - "read:\n defaultRepresentation: public\n representations:\n public: {access: public, disclosureProfile: public}", - "read:\n defaultRepresentation: public\n representations:\n public: {access: public, disclosureProfile: public}\n alternate: {access: public, disclosureProfile: public}\n list:\n defaultRepresentation: listing\n representations:\n listing: {access: public, disclosureProfile: public}\n filters: []\n allowUnfiltered: true\n orderBy: [name]\n pagination: {defaultPageSize: 1, maximumPageSize: 10}", + "read:\n defaultAccessProfile: public\n accessProfiles:\n public: {access: public, disclosureProfile: public}", + "read:\n defaultAccessProfile: public\n accessProfiles:\n public: {access: public, disclosureProfile: public}\n alternate: {access: public, disclosureProfile: public}\n list:\n defaultAccessProfile: listing\n accessProfiles:\n listing: {access: public, disclosureProfile: public}\n filters: []\n allowUnfiltered: true\n orderBy: [name]\n pagination: {defaultPageSize: 1, maximumPageSize: 10}", ) .replace("operationRefs: [read]", "operationRefs: [read, list]"); let contract = RegistryContract::parse_yaml(&yaml).expect("strict multi-profile contract"); @@ -1025,7 +1025,7 @@ mod tests { ) .expect("multi-profile Registry compiles"); let artifacts = generate_artifacts(®istry).expect("multi-profile artifacts generate"); - let expected = operation_representation_pairs(®istry); + let expected = operation_access_profile_pairs(®istry); let artifact_paths = artifacts .artifacts .iter() @@ -1056,17 +1056,17 @@ mod tests { )); let mut cross_operation = artifacts.operation_bindings.clone(); - let listing_representation = cross_operation + let listing_access_profile = cross_operation .iter() .find(|binding| binding.operation_identifier.ends_with(".list")) .expect("list binding") - .representation_identifier + .access_profile_identifier .clone(); let read_binding = cross_operation .iter_mut() .find(|binding| binding.operation_identifier.ends_with(".read")) .expect("read binding"); - read_binding.representation_identifier = listing_representation; + read_binding.access_profile_identifier = listing_access_profile; assert!(!valid_operation_artifact_bindings( &cross_operation, &expected, @@ -1312,6 +1312,15 @@ mod tests { manifest.operation_artifact_bindings, artifacts.operation_bindings ); + assert_eq!(manifest.package_version, PACKAGE_VERSION); + let serialized_manifest = serde_json::to_value(&manifest).expect("manifest serializes"); + assert!(serialized_manifest["artifacts"] + .as_array() + .expect("artifact array") + .iter() + .filter(|artifact| artifact["operationIdentifier"].is_string()) + .all(|artifact| artifact.get("accessProfileIdentifier").is_some() + && artifact.get("representationIdentifier").is_none())); assert_resealed_package_rejected( temporary.path(), diff --git a/crates/registry-relay-v2/src/problem.rs b/crates/registry-relay-v2/src/problem.rs index 3e3408654..c12490172 100644 --- a/crates/registry-relay-v2/src/problem.rs +++ b/crates/registry-relay-v2/src/problem.rs @@ -21,13 +21,13 @@ pub enum ProblemCode { UnknownFilter, InvalidFilter, CursorInvalid, - RepresentationInvalid, + AccessProfileInvalid, MissingCredential, InvalidCredential, ConsultationDenied, ResourceNotFound, ConsultationUnresolved, - UnsupportedRepresentation, + UnsupportedFormat, BodyTooLarge, UriTooLong, UnsupportedMediaType, @@ -48,13 +48,13 @@ impl ProblemCode { Self::UnknownFilter => "filter.unknown_field", Self::InvalidFilter => "filter.invalid_value", Self::CursorInvalid => "query.cursor_invalid", - Self::RepresentationInvalid => "request.representation_invalid", + Self::AccessProfileInvalid => "request.access_profile_invalid", Self::MissingCredential => "auth.missing_credential", Self::InvalidCredential => "auth.invalid_credential", Self::ConsultationDenied => "consultation.denied", Self::ResourceNotFound => "resource.not_found", Self::ConsultationUnresolved => "consultation.unresolved", - Self::UnsupportedRepresentation => "representation.unsupported", + Self::UnsupportedFormat => "format.unsupported", Self::BodyTooLarge => "internal.payload_too_large", Self::UriTooLong => "internal.uri_too_long", Self::UnsupportedMediaType => "request.media_type_unsupported", @@ -75,13 +75,13 @@ impl ProblemCode { Self::UnknownFilter => "Filter is not declared", Self::InvalidFilter => "Filter value is invalid", Self::CursorInvalid => "Cursor is invalid", - Self::RepresentationInvalid => "Representation selection 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::ResourceNotFound => "Requested resource was not found", Self::ConsultationUnresolved => "Requested record was not resolved", - Self::UnsupportedRepresentation => "Requested representation is not supported", + Self::UnsupportedFormat => "Requested format is not supported", Self::BodyTooLarge => "Request body is too large", Self::UriTooLong => "Request URI is too long", Self::UnsupportedMediaType => "Request media type is not supported", @@ -102,11 +102,11 @@ impl ProblemCode { | Self::UnknownFilter | Self::InvalidFilter | Self::CursorInvalid - | Self::RepresentationInvalid => 400, + | Self::AccessProfileInvalid => 400, Self::MissingCredential | Self::InvalidCredential => 401, Self::ConsultationDenied => 403, Self::ResourceNotFound | Self::ConsultationUnresolved => 404, - Self::UnsupportedRepresentation => 406, + Self::UnsupportedFormat => 406, Self::BodyTooLarge => 413, Self::UriTooLong => 414, Self::UnsupportedMediaType => 415, @@ -170,13 +170,13 @@ impl ProblemCode { Self::UnknownFilter => "filter is not declared for this operation", Self::InvalidFilter => "filter value is invalid", Self::CursorInvalid => "cursor is invalid for this query", - Self::RepresentationInvalid => "representation selection is invalid", + 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::ResourceNotFound => "the requested resource was not found", Self::ConsultationUnresolved => "the requested record was not resolved", - Self::UnsupportedRepresentation => "the requested representation is not supported", + Self::UnsupportedFormat => "the requested format is not supported", Self::BodyTooLarge => "request body exceeds the configured limit", Self::UriTooLong => "request URI exceeds the configured limit", Self::UnsupportedMediaType => "request body must use application/json", @@ -327,6 +327,17 @@ mod tests { ); } + #[test] + fn access_profile_and_wire_format_failures_are_distinct() { + assert_eq!( + ProblemCode::AccessProfileInvalid.code(), + "request.access_profile_invalid" + ); + assert_eq!(ProblemCode::AccessProfileInvalid.status(), 400); + assert_eq!(ProblemCode::UnsupportedFormat.code(), "format.unsupported"); + assert_eq!(ProblemCode::UnsupportedFormat.status(), 406); + } + #[test] fn trace_identifier_has_one_canonical_wire_shape() { assert!(TraceId::parse("0123456789abcdef0123456789abcdef").is_ok()); diff --git a/crates/registry-relay-v2/src/semantics.rs b/crates/registry-relay-v2/src/semantics.rs index ae52aaab4..549aeeb87 100644 --- a/crates/registry-relay-v2/src/semantics.rs +++ b/crates/registry-relay-v2/src/semantics.rs @@ -120,7 +120,7 @@ pub fn json_ld_context( json!({"@context": context}) } -pub fn representation_schema( +pub fn access_profile_schema( registry: &CompiledRegistry, resource: &CompiledResource, selected: &[String], @@ -254,7 +254,7 @@ fn point_geometry_schema() -> Value { }) } -pub fn representation_shacl( +pub fn access_profile_shacl( registry: &CompiledRegistry, resource: &CompiledResource, selected: &[String], @@ -550,7 +550,7 @@ mod tests { }); let selected = vec!["name".into(), "location".into()]; - let schema = representation_schema( + let schema = access_profile_schema( ®istry, &resource, &selected, @@ -575,7 +575,7 @@ mod tests { let encoded = serde_json::to_string(&vocabulary).expect("vocabulary serializes"); assert!(encoded.contains("rdf:JSON")); assert!(!encoded.to_ascii_lowercase().contains("geosparql")); - let shacl = representation_shacl(®istry, &resource, &selected); + let shacl = access_profile_shacl(®istry, &resource, &selected); assert!(shacl.contains("rdf-syntax-ns#JSON")); assert!(!shacl.to_ascii_lowercase().contains("geosparql")); } diff --git a/crates/registry-relay-v2/src/server.rs b/crates/registry-relay-v2/src/server.rs index 028ce463d..940f3db13 100644 --- a/crates/registry-relay-v2/src/server.rs +++ b/crates/registry-relay-v2/src/server.rs @@ -131,6 +131,10 @@ pub fn router(service: Arc) -> Router { "/v2/resources/{resource}/lookups/{lookup}", post(crate::api::record_lookup), ) + .route( + "/v2/resources/{resource}/searches/{search}", + get(crate::api::record_search), + ) .route( "/v2/artifacts/{artifact_identifier}", get(crate::api::artifact), @@ -231,6 +235,17 @@ fn operational_route(uri: &http::Uri) -> &'static str { ) if !resource.is_empty() && !lookup.is_empty() => { "/v2/resources/{resource}/lookups/{lookup}" } + ( + Some("v2"), + Some("resources"), + Some(resource), + Some("searches"), + Some(search), + None, + None, + ) if !resource.is_empty() && !search.is_empty() => { + "/v2/resources/{resource}/searches/{search}" + } (Some("v2"), Some("artifacts"), Some(artifact_identifier), None, None, None, None) if !artifact_identifier.is_empty() => { @@ -341,6 +356,16 @@ mod tests { ); assert!(!operational_route(&uri).contains("private-registry")); assert!(!operational_route(&uri).contains("protected-record")); + let search_uri = "/v2/resources/private-registry/searches/within-bbox?bbox=100,10,101,11" + .parse() + .expect("search URI"); + assert_eq!( + operational_route(&search_uri), + "/v2/resources/{resource}/searches/{search}" + ); + assert!(!operational_route(&search_uri).contains("private-registry")); + assert!(!operational_route(&search_uri).contains("within-bbox")); + assert!(!operational_route(&search_uri).contains("100")); assert_eq!( operational_method(&http::Method::from_bytes(b"ATTACKER-CONTROLLED").expect("method")), "OTHER" diff --git a/crates/registry-relay-v2/src/sqlite_runtime.rs b/crates/registry-relay-v2/src/sqlite_runtime.rs index 52eac7c78..49017bfe6 100644 --- a/crates/registry-relay-v2/src/sqlite_runtime.rs +++ b/crates/registry-relay-v2/src/sqlite_runtime.rs @@ -21,7 +21,7 @@ use tokio::sync::{OwnedSemaphorePermit, Semaphore}; use crate::auth::RowAuthority; use crate::contract::{DataType, SourceProfile}; use crate::model::{ - CompiledOperation, CompiledRegistry, CompiledRepresentation, CompiledResource, OperationKind, + CompiledAccessProfile, CompiledOperation, CompiledRegistry, CompiledResource, OperationKind, }; const MAXIMUM_CELL_BYTES: usize = 1024 * 1024; @@ -124,13 +124,13 @@ pub enum SqliteRuntimeError { struct OperationExecutor { statement: Arc, operation: CompiledOperation, - representation: CompiledRepresentation, + access_profile: CompiledAccessProfile, source_revision: SourceRevision, } struct OperationInventory { source_revision: SourceRevision, - representations: BTreeMap, + access_profiles: BTreeMap, } #[derive(Clone)] @@ -207,23 +207,23 @@ impl SqliteRuntime { .iter() .find(|source| source.id == operation.query.source) .ok_or(SqliteRuntimeError::MissingSource)?; - let mut representations = BTreeMap::new(); - for representation in &operation.representations { + let mut access_profiles = BTreeMap::new(); + for access_profile in &operation.access_profiles { let contract = statement_contract( resource, operation, - representation, + access_profile, &limits, &source.expected_schema_fingerprint, )?; let statement = ReadOnlyStatement::open(profile.clone(), contract)?; - if representations + if access_profiles .insert( - representation.id.clone(), + access_profile.id.clone(), OperationExecutor { statement: Arc::new(statement), operation: operation.clone(), - representation: representation.clone(), + access_profile: access_profile.clone(), source_revision: source_revision.clone(), }, ) @@ -232,13 +232,13 @@ impl SqliteRuntime { return Err(SqliteRuntimeError::InvalidPlan); } } - if representations.is_empty() + if access_profiles.is_empty() || operations .insert( operation.identifier.clone(), OperationInventory { source_revision: source_revision.clone(), - representations, + access_profiles, }, ) .is_some() @@ -295,16 +295,16 @@ impl SqliteRuntime { pub async fn execute( &self, operation: &str, - representation: &str, + access_profile: &str, query: OperationQuery, ) -> Result { let executor = self .operations .get(operation) - .and_then(|inventory| inventory.representations.get(representation)) + .and_then(|inventory| inventory.access_profiles.get(access_profile)) .ok_or(SqliteRuntimeError::UnknownOperation)?; let permit = self.acquire().await?; - let values = bind_operation_values(&executor.operation, &executor.representation, query)?; + let values = bind_operation_values(&executor.operation, &executor.access_profile, query)?; let result = executor.statement.execute(&values).await; drop(permit); Ok(OperationResult { @@ -324,11 +324,11 @@ impl SqliteRuntime { fn statement_contract( resource: &CompiledResource, operation: &CompiledOperation, - representation: &CompiledRepresentation, + access_profile: &CompiledAccessProfile, limits: &SqliteRuntimeLimits, expected_schema_fingerprint: &str, ) -> Result { - let result_columns = result_columns(operation, representation); + let result_columns = result_columns(operation, access_profile); let columns = result_columns .iter() .map(|column| ColumnContract { @@ -338,22 +338,22 @@ fn statement_contract( .collect::>(); let mut parameters = Vec::new(); let sql = match &operation.kind { - OperationKind::List => { - list_sql(operation, representation, &result_columns, &mut parameters) + OperationKind::List | OperationKind::Search { .. } => { + collection_sql(operation, access_profile, &result_columns, &mut parameters) } OperationKind::Read => read_sql( resource, operation, - representation, + access_profile, &result_columns, &mut parameters, ), OperationKind::Lookup { .. } => { - lookup_sql(operation, representation, &result_columns, &mut parameters) + lookup_sql(operation, access_profile, &result_columns, &mut parameters) } }; let maximum_rows = match &operation.kind { - OperationKind::List => u64::from( + OperationKind::List | OperationKind::Search { .. } => u64::from( operation .query .pagination @@ -375,8 +375,8 @@ fn statement_contract( maximum_statement_steps: MAXIMUM_STATEMENT_STEPS, timeout: limits.request_timeout, // Aggregate process concurrency is owned above. Each fixed - // representation has one connection, and compilation bounds the - // Registry-wide representation executor inventory. + // access_profile has one connection, and compilation bounds the + // Registry-wide access_profile executor inventory. concurrency: 1, }, schema: Some(SchemaBinding { @@ -389,9 +389,9 @@ fn statement_contract( fn result_columns( operation: &CompiledOperation, - representation: &CompiledRepresentation, + access_profile: &CompiledAccessProfile, ) -> Vec { - let mut columns = representation.projected_columns.clone(); + let mut columns = access_profile.projected_columns.clone(); for column in &operation.query.order_by { if !columns.contains(column) { columns.push(column.clone()); @@ -427,9 +427,9 @@ fn data_type(value: DataType) -> ColumnType { } } -fn list_sql( +fn collection_sql( operation: &CompiledOperation, - representation: &CompiledRepresentation, + access_profile: &CompiledAccessProfile, columns: &[String], parameters: &mut Vec, ) -> String { @@ -445,24 +445,18 @@ fn list_sql( )); } if let Some(spatial) = &operation.query.spatial_bbox { - for name in [ - "bbox_present", - "bbox_west", - "bbox_south", - "bbox_east", - "bbox_north", - ] { + for name in ["bbox_west", "bbox_south", "bbox_east", "bbox_north"] { parameters.push(parameter(name)); } predicates.push(format!( - "(:bbox_present = 0 OR ({} >= :bbox_south AND {} <= :bbox_north AND {} >= :bbox_west AND {} <= :bbox_east))", + "({} >= :bbox_south AND {} <= :bbox_north AND {} >= :bbox_west AND {} <= :bbox_east)", quote_identifier(&spatial.latitude_column), quote_identifier(&spatial.latitude_column), quote_identifier(&spatial.longitude_column), quote_identifier(&spatial.longitude_column), )); } - add_row_authority(representation, parameters, &mut predicates); + add_row_authority(access_profile, parameters, &mut predicates); parameters.push(parameter("cursor_present")); let keyset = keyset_predicate(&operation.query.order_by, parameters); predicates.push(format!("(:cursor_present = 0 OR ({keyset}))")); @@ -485,7 +479,7 @@ fn list_sql( fn read_sql( resource: &CompiledResource, operation: &CompiledOperation, - representation: &CompiledRepresentation, + access_profile: &CompiledAccessProfile, columns: &[String], parameters: &mut Vec, ) -> String { @@ -494,7 +488,7 @@ fn read_sql( "{} = :record_identifier", quote_identifier(&resource.record_context.record_identifier_column) )]; - add_row_authority(representation, parameters, &mut predicates); + add_row_authority(access_profile, parameters, &mut predicates); format!( "SELECT {} FROM {} WHERE {} LIMIT 2", select_list(columns), @@ -505,7 +499,7 @@ fn read_sql( fn lookup_sql( operation: &CompiledOperation, - representation: &CompiledRepresentation, + access_profile: &CompiledAccessProfile, columns: &[String], parameters: &mut Vec, ) -> String { @@ -518,7 +512,7 @@ fn lookup_sql( quote_identifier(&selector.source_column) )); } - add_row_authority(representation, parameters, &mut predicates); + add_row_authority(access_profile, parameters, &mut predicates); format!( "SELECT {} FROM {} WHERE {} LIMIT 2", select_list(columns), @@ -528,14 +522,14 @@ fn lookup_sql( } fn add_row_authority( - representation: &CompiledRepresentation, + access_profile: &CompiledAccessProfile, parameters: &mut Vec, predicates: &mut Vec, ) { if let crate::model::CompiledAccess::Protected { row_binding: Some(binding), .. - } = &representation.access + } = &access_profile.access { parameters.push(parameter("row_authority")); predicates.push(format!( @@ -585,12 +579,12 @@ fn quote_identifier(value: &str) -> String { fn bind_operation_values( operation: &CompiledOperation, - representation: &CompiledRepresentation, + access_profile: &CompiledAccessProfile, query: OperationQuery, ) -> Result, SqliteRuntimeError> { let mut values = BTreeMap::new(); match &operation.kind { - OperationKind::List => { + OperationKind::List | OperationKind::Search { .. } => { let declared = operation .query .filters @@ -612,34 +606,18 @@ fn bind_operation_values( ); values.insert(format!("filter_{index}"), value.unwrap_or(Value::Null)); } - match (&operation.query.spatial_bbox, query.bbox) { - (Some(spatial), bbox) => { - if bbox.is_some_and(|value| !value.is_within(spatial)) { + match (&operation.kind, &operation.query.spatial_bbox, query.bbox) { + (OperationKind::Search { .. }, Some(spatial), Some(bbox)) => { + if !bbox.is_within(spatial) { return Err(SqliteRuntimeError::InvalidPlan); } - values.insert( - "bbox_present".into(), - Value::Integer(i64::from(bbox.is_some())), - ); - values.insert( - "bbox_west".into(), - bbox.map_or(Value::Null, |value| Value::Number(value.west)), - ); - values.insert( - "bbox_south".into(), - bbox.map_or(Value::Null, |value| Value::Number(value.south)), - ); - values.insert( - "bbox_east".into(), - bbox.map_or(Value::Null, |value| Value::Number(value.east)), - ); - values.insert( - "bbox_north".into(), - bbox.map_or(Value::Null, |value| Value::Number(value.north)), - ); + values.insert("bbox_west".into(), Value::Number(bbox.west)); + values.insert("bbox_south".into(), Value::Number(bbox.south)); + values.insert("bbox_east".into(), Value::Number(bbox.east)); + values.insert("bbox_north".into(), Value::Number(bbox.north)); } - (None, None) => {} - (None, Some(_)) => return Err(SqliteRuntimeError::InvalidPlan), + (OperationKind::List, None, None) => {} + _ => return Err(SqliteRuntimeError::InvalidPlan), } let after = query.after_order.unwrap_or_default(); if !after.is_empty() && after.len() != operation.query.order_by.len() { @@ -697,7 +675,7 @@ fn bind_operation_values( if let crate::model::CompiledAccess::Protected { row_binding: Some(binding), .. - } = &representation.access + } = &access_profile.access { let row = query.row_authority.ok_or(SqliteRuntimeError::InvalidPlan)?; if row.source_column != binding.source_column { @@ -713,6 +691,63 @@ fn bind_operation_values( #[cfg(test)] mod tests { use super::*; + use crate::contract::Handling; + use crate::model::{ + CapabilityFamily, CompiledAccess, CompiledPagination, CompiledSpatialBboxQuery, + ConsultationPattern, QueryPlan, + }; + + fn public_access_profile() -> CompiledAccessProfile { + CompiledAccessProfile { + id: "public".into(), + access: CompiledAccess::Public, + disclosure_profile: "public".into(), + selectable_properties: vec!["identifier".into()], + projected_columns: vec!["identifier".into()], + processing_handling: Handling::Public, + disclosure_handling: Handling::Public, + transform_inventory: Vec::new(), + schema_reference: "schema".into(), + semantic_model_reference: "semantic-model".into(), + context_reference: "context".into(), + } + } + + fn collection_operation(kind: OperationKind) -> CompiledOperation { + let spatial_bbox = + matches!(&kind, OperationKind::Search { .. }).then(|| CompiledSpatialBboxQuery { + longitude_column: "longitude".into(), + latitude_column: "latitude".into(), + maximum_longitude_span_degrees: 2, + maximum_latitude_span_degrees: 2, + }); + CompiledOperation { + identifier: "resource.search.within-bbox".into(), + family: CapabilityFamily::Consultation, + pattern: if spatial_bbox.is_some() { + ConsultationPattern::Search + } else { + ConsultationPattern::List + }, + kind, + default_access_profile: "public".into(), + access_profiles: vec![public_access_profile()], + query: QueryPlan { + source: "source".into(), + view: "records".into(), + filters: Vec::new(), + spatial_bbox, + selectors: Vec::new(), + order_by: vec!["identifier".into()], + allow_unfiltered: true, + pagination: Some(CompiledPagination { + default_page_size: 10, + maximum_page_size: 100, + }), + maximum_request_body_bytes: None, + }, + } + } #[test] fn point_bbox_validation_is_numeric_and_crs84_bounded() { @@ -775,4 +810,50 @@ mod tests { } .is_valid()); } + + #[test] + fn named_search_requires_one_bounded_bbox_and_list_refuses_it() { + let access_profile = public_access_profile(); + let search = collection_operation(OperationKind::Search { + name: "within-bbox".into(), + }); + let base = OperationQuery { + fetch_limit: Some(11), + ..OperationQuery::default() + }; + assert!(matches!( + bind_operation_values(&search, &access_profile, base.clone()), + Err(SqliteRuntimeError::InvalidPlan) + )); + let bbox = PointBbox { + west: 100.0, + south: 10.0, + east: 101.0, + north: 11.0, + }; + let values = bind_operation_values( + &search, + &access_profile, + OperationQuery { + bbox: Some(bbox), + ..base.clone() + }, + ) + .expect("named search binds its bbox"); + assert_eq!(values.get("bbox_west"), Some(&Value::Number(100.0))); + assert!(!values.contains_key("bbox_present")); + + let list = collection_operation(OperationKind::List); + assert!(matches!( + bind_operation_values( + &list, + &access_profile, + OperationQuery { + bbox: Some(bbox), + ..base + }, + ), + Err(SqliteRuntimeError::InvalidPlan) + )); + } } diff --git a/crates/registry-relay-v2/src/startup.rs b/crates/registry-relay-v2/src/startup.rs index 52557361f..b8313983a 100644 --- a/crates/registry-relay-v2/src/startup.rs +++ b/crates/registry-relay-v2/src/startup.rs @@ -474,11 +474,10 @@ fn validate_runtime_contract( if governed != bound { return Err(StartupError::RuntimeInvalid); } - let has_list = contract - .resources - .iter() - .any(|resource| resource.operations.list.is_some()); - if has_list && runtime.cursor.is_none() { + let has_paginated_operation = contract.resources.iter().any(|resource| { + resource.operations.list.is_some() || !resource.operations.searches.is_empty() + }); + if has_paginated_operation && runtime.cursor.is_none() { return Err(StartupError::CursorInvalid); } let protected = contract.resources.iter().any(|resource| { @@ -488,19 +487,25 @@ fn validate_runtime_contract( .iter() .flat_map(|operation| { operation - .representations + .access_profiles .iter() .map(|(_, item)| &item.access) }) .chain(resource.operations.read.iter().flat_map(|operation| { operation - .representations + .access_profiles .iter() .map(|(_, item)| &item.access) })) .chain(resource.operations.lookups.iter().flat_map(|operation| { operation - .representations + .access_profiles + .iter() + .map(|(_, item)| &item.access) + })) + .chain(resource.operations.searches.iter().flat_map(|operation| { + operation + .access_profiles .iter() .map(|(_, item)| &item.access) })) @@ -959,7 +964,8 @@ mod tests { } #[test] - fn protected_contracts_require_issuer_lists_require_cursor_and_lookups_require_quota() { + fn protected_contracts_require_issuer_paginated_operations_require_cursor_and_lookups_require_quota( + ) { fn contract(operations: &str) -> RegistryContract { let yaml = r#" apiVersion: relay.registrystack.org/v2alpha1 @@ -1005,7 +1011,7 @@ metadataVisibility: {service: public, resources: public, semantics: public, clas } let protected = contract( - "{read: {defaultRepresentation: default, representations: {default: {access: {scope: registry:record:read}, disclosureProfile: default}}}}", + "{read: {defaultAccessProfile: default, accessProfiles: {default: {access: {scope: registry:record:read}, disclosureProfile: default}}}}", ); let protected_runtime = RelayRuntime::parse_yaml( "apiVersion: relay.registrystack.org/v2alpha1\nkind: RelayRuntime\nserver: {bind: '127.0.0.1:18081'}\npackagePath: package\nsources: {records: {path: fixture.sqlite}}\nauthentication: {issuer: null}\naudit: {sink: var/audit.jsonl, integrityKeyRef: secret:env/KEY}\nlimits: {requestTimeoutMilliseconds: 1000, concurrentQueries: 1}\n", @@ -1017,7 +1023,7 @@ metadataVisibility: {service: public, resources: public, semantics: public, clas ); let list = contract( - "{list: {defaultRepresentation: default, representations: {default: {access: public, disclosureProfile: default}}, filters: [], allowUnfiltered: true, orderBy: [id], pagination: {defaultPageSize: 10, maximumPageSize: 20}}}", + "{list: {defaultAccessProfile: default, accessProfiles: {default: {access: public, disclosureProfile: default}}, filters: [], allowUnfiltered: true, orderBy: [id], pagination: {defaultPageSize: 10, maximumPageSize: 20}}}", ); let list_runtime = RelayRuntime::parse_yaml( "apiVersion: relay.registrystack.org/v2alpha1\nkind: RelayRuntime\nserver: {bind: '127.0.0.1:18082'}\npackagePath: package\nsources: {records: {path: fixture.sqlite}}\nauthentication: {issuer: null}\naudit: {sink: var/audit.jsonl, integrityKeyRef: secret:env/KEY}\nlimits: {requestTimeoutMilliseconds: 1000, concurrentQueries: 1}\n", @@ -1028,8 +1034,29 @@ metadataVisibility: {service: public, resources: public, semantics: public, clas Err(StartupError::CursorInvalid) ); + let search = contract( + "{searches: [{id: within-bbox, query: {kind: point-bbox, maximumLongitudeSpanDegrees: 10, maximumLatitudeSpanDegrees: 10}, defaultAccessProfile: default, accessProfiles: {default: {access: public, disclosureProfile: default}}, orderBy: [id], pagination: {defaultPageSize: 10, maximumPageSize: 20}}]}", + ); + assert_eq!( + validate_runtime_contract(&list_runtime, &search), + Err(StartupError::CursorInvalid) + ); + + let protected_search = contract( + "{searches: [{id: within-bbox, query: {kind: point-bbox, maximumLongitudeSpanDegrees: 10, maximumLatitudeSpanDegrees: 10}, defaultAccessProfile: default, accessProfiles: {default: {access: {scope: registry:record:search}, disclosureProfile: default}}, orderBy: [id], pagination: {defaultPageSize: 10, maximumPageSize: 20}}]}", + ); + let mut protected_search_runtime = list_runtime.clone(); + protected_search_runtime.cursor = Some(crate::contract::CursorRuntime { + integrity_key_ref: "secret:env/CURSOR_KEY".into(), + maximum_age_seconds: 300, + }); + assert_eq!( + validate_runtime_contract(&protected_search_runtime, &protected_search), + Err(StartupError::IssuerUnavailable) + ); + let lookup = contract( - "{lookups: [{id: by-label, requestBody: {maximumBytes: 128, selectors: {label: {sourceColumn: label, type: string, minimumBytes: 1, maximumBytes: 32}}}, defaultRepresentation: default, representations: {default: {access: public, disclosureProfile: default}}}]}", + "{lookups: [{id: by-label, requestBody: {maximumBytes: 128, selectors: {label: {sourceColumn: label, type: string, minimumBytes: 1, maximumBytes: 32}}}, defaultAccessProfile: default, accessProfiles: {default: {access: public, disclosureProfile: default}}}]}", ); let mut lookup_runtime = RelayRuntime::parse_yaml( "apiVersion: relay.registrystack.org/v2alpha1\nkind: RelayRuntime\nserver: {bind: '127.0.0.1:18083'}\npackagePath: package\nsources: {records: {path: fixture.sqlite}}\nauthentication: {issuer: null}\naudit: {sink: var/audit.jsonl, integrityKeyRef: secret:env/KEY}\nlimits: {requestTimeoutMilliseconds: 1000, concurrentQueries: 1}\n", diff --git a/crates/registry-relay-v2/src/tooling.rs b/crates/registry-relay-v2/src/tooling.rs index 0c6b0d194..bba8371ac 100644 --- a/crates/registry-relay-v2/src/tooling.rs +++ b/crates/registry-relay-v2/src/tooling.rs @@ -30,11 +30,11 @@ use crate::fixtures::{ }; use crate::identification::{ classification_inventory_report, classification_review_starter, contextual_review_findings, - identify_contract, render_classification_inventory_report, render_classification_review_yaml, - render_contextual_review_findings, render_identification_report, render_representation_report, - representation_report, CLASSIFICATION_INVENTORY_REPORT_PATH, - CLASSIFICATION_REVIEW_STARTER_PATH, CONTEXTUAL_REVIEW_FINDINGS_PATH, - IDENTIFICATION_REPORT_PATH, REPRESENTATION_REPORT_PATH, + identify_contract, operation_explanation, render_classification_inventory_report, + render_classification_review_yaml, render_contextual_review_findings, + render_identification_report, render_operation_explanation, OperationExplanation, + CLASSIFICATION_INVENTORY_REPORT_PATH, CLASSIFICATION_REVIEW_STARTER_PATH, + CONTEXTUAL_REVIEW_FINDINGS_PATH, IDENTIFICATION_REPORT_PATH, OPERATION_EXPLANATION_PATH, }; use crate::model::{ CompileProfile, CompileReport, CompiledRegistry, Diagnostic, DiagnosticSeverity, @@ -61,6 +61,7 @@ pub struct InspectOptions { pub struct CheckOptions { pub project_root: PathBuf, pub production: bool, + pub explain: bool, } #[derive(Clone, Debug)] @@ -139,6 +140,8 @@ pub enum ToolingDetails { contract_revision: Option, production: bool, configuration_key_paths: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + operation_explanation: Option, }, Generate { contract_revision: Option, @@ -210,6 +213,8 @@ pub enum ToolingError { Inspect, #[error("the generated artifacts could not be constructed")] Generate, + #[error("the authoring explanation could not be constructed")] + Explain, #[error("the deployment package could not be constructed")] Package, } @@ -224,6 +229,7 @@ impl ToolingError { Self::UnsafePath => "the requested path is unsafe", Self::Inspect => "the SQLite schema could not be inspected", Self::Generate => "the generated artifacts could not be constructed", + Self::Explain => "the authoring explanation could not be constructed", Self::Package => "the deployment package could not be constructed", } } @@ -335,6 +341,16 @@ pub fn check_project(options: &CheckOptions) -> Result { + let operation_explanation = if options.explain { + let classification_digest = classification_inventory_digest(&project.registry) + .map_err(|_| ToolingError::Explain)?; + Some( + operation_explanation(&project.registry, &classification_digest) + .map_err(|_| ToolingError::Explain)?, + ) + } else { + None + }; let configuration_key_paths = ConfigurationKeyPaths { registry: collect_configuration_key_paths( &serde_json::to_value(&project.contract).map_err(|_| ToolingError::Inspect)?, @@ -352,6 +368,7 @@ pub fn check_project(options: &CheckOptions) -> Result Ok(ToolingReport::refused( @@ -360,6 +377,7 @@ pub fn check_project(options: &CheckOptions) -> Result Result Result (tempfile::TempDir, PathBuf) { + let temporary = tempfile::tempdir().expect("temporary project root creates"); + let project = temporary.path().join("project"); + let report = init_project(&InitOptions { + project_root: project.clone(), + }) + .expect("generic project initializes"); + assert!(report.is_success()); + fs::remove_file(project.join("runtime.yaml")) + .expect("runtime is optional for an authoring check"); + fs::write( + project.join("fixture.sql"), + "-- ROW-VALUE-CANARY REQUEST-VALUE-CANARY PRINCIPAL-VALUE-CANARY\n", + ) + .expect("generic value canaries write"); + (temporary, project) + } + #[test] fn errors_never_render_paths() { for error in [ @@ -1164,6 +1203,7 @@ mod tests { ToolingError::UnsafePath, ToolingError::Inspect, ToolingError::Generate, + ToolingError::Explain, ToolingError::Package, ] { assert!(!error.safe_message().contains('/')); @@ -1176,6 +1216,129 @@ mod tests { assert!(RelayRuntime::parse_yaml(STARTER_RUNTIME).is_ok()); } + #[test] + fn check_explanation_is_deterministic_read_only_and_value_free() { + let (_temporary, project) = generic_project(); + let fixture_path = project.join("fixture.sqlite"); + let generated_path = project.join("generated"); + let fixture_sql = fs::read(project.join("fixture.sql")).expect("fixture canaries read"); + assert!(!fixture_path.exists()); + assert!(!generated_path.exists()); + + let options = CheckOptions { + project_root: project.clone(), + production: false, + explain: true, + }; + let first = check_project(&options).expect("first explanation check completes"); + let second = check_project(&options).expect("second explanation check completes"); + assert!(first.is_success(), "{first:?}"); + assert_eq!(first, second); + + let ToolingDetails::Check { + operation_explanation: Some(explanation), + .. + } = &first.details + else { + panic!("successful explanation check returns its canonical explanation"); + }; + let rendered = render_operation_explanation(explanation) + .expect("operation explanation renders canonically"); + for canary in [ + "ROW-VALUE-CANARY", + "REQUEST-VALUE-CANARY", + "PRINCIPAL-VALUE-CANARY", + ] { + assert!( + !rendered + .windows(canary.len()) + .any(|window| window == canary.as_bytes()), + "operation explanation leaked fixture value" + ); + } + assert_eq!( + fs::read(project.join("fixture.sql")).expect("fixture canaries reread"), + fixture_sql + ); + assert!(!fixture_path.exists()); + assert!(!generated_path.exists()); + } + + #[test] + fn plain_check_omits_the_optional_explanation() { + let (_temporary, project) = generic_project(); + let report = check_project(&CheckOptions { + project_root: project, + production: false, + explain: false, + }) + .expect("plain check completes"); + assert!(report.is_success(), "{report:?}"); + + let value = serde_json::to_value(report).expect("report serializes"); + assert!(value["details"].get("operation_explanation").is_none()); + } + + #[test] + fn refused_check_returns_no_partial_explanation() { + let temporary = tempfile::tempdir().expect("temporary project creates"); + fs::write(temporary.path().join("registry.yaml"), "not: [valid") + .expect("invalid contract writes"); + + let report = check_project(&CheckOptions { + project_root: temporary.path().to_path_buf(), + production: false, + explain: true, + }) + .expect("invalid project produces a refusal"); + assert!(!report.is_success()); + let ToolingDetails::Check { + operation_explanation, + .. + } = report.details + else { + panic!("check returns check details"); + }; + assert!(operation_explanation.is_none()); + } + + #[test] + fn generate_writes_the_same_canonical_operation_explanation_as_check() { + let (_project_temporary, project) = generic_project(); + let check = check_project(&CheckOptions { + project_root: project.clone(), + production: false, + explain: true, + }) + .expect("explanation check completes"); + let ToolingDetails::Check { + operation_explanation: Some(explanation), + .. + } = check.details + else { + panic!("check returns an explanation"); + }; + let expected = render_operation_explanation(&explanation) + .expect("operation explanation renders canonically"); + + let temporary = tempfile::tempdir().expect("temporary output creates"); + let report = generate_project(&GenerateOptions { + project_root: project, + output_dir: Some(temporary.path().to_path_buf()), + }) + .expect("generation completes"); + assert!(report.is_success(), "{report:?}"); + assert_eq!( + fs::read(temporary.path().join(OPERATION_EXPLANATION_PATH)) + .expect("generated explanation reads"), + expected + ); + assert!(!temporary + .path() + .join("reports/representation-report.json") + .exists()); + } + #[test] fn fixture_execution_is_isolated_and_reports_no_row_values() { let project_name = ["business", "registry"].join("-"); diff --git a/crates/registry-relay-v2/tests/acceptance_http.rs b/crates/registry-relay-v2/tests/acceptance_http.rs index 53e324bb7..8fb39d6ab 100644 --- a/crates/registry-relay-v2/tests/acceptance_http.rs +++ b/crates/registry-relay-v2/tests/acceptance_http.rs @@ -39,9 +39,9 @@ use registry_relay_v2::compiler::{ use registry_relay_v2::contract::{RegistryContract, RelayRuntime}; use registry_relay_v2::fixture_contract::{ parse_journey, FixtureAuthorization as AuthorizationFixture, - FixtureExpectation as JourneyExpectation, FixtureGeoJsonRoot as JourneyGeoJsonRoot, - FixtureGeometryType as JourneyGeometryType, FixtureJourney as Journey, FixtureMethod, - FixtureRepresentationProfile as JourneyRepresentationProfile, FixtureStep as JourneyStep, + FixtureExpectation as JourneyExpectation, FixtureFormatProfile as JourneyFormatProfile, + FixtureGeoJsonRoot as JourneyGeoJsonRoot, FixtureGeometryType as JourneyGeometryType, + FixtureJourney as Journey, FixtureMethod, FixtureStep as JourneyStep, }; use registry_relay_v2::identification::{ parse_classification_review_yaml, render_classification_review_yaml, @@ -614,7 +614,7 @@ async fn audit_terminal_failure_discards_held_record_bytes() { } #[tokio::test] -async fn spatial_representations_validate_and_keep_distinct_cache_identities() { +async fn spatial_formats_validate_and_keep_distinct_cache_identities() { let harness = ProjectHarness::open("business-registry").await; let path = "/v2/resources/registered-premises/records/PREM-SYNTH-0001"; let (json_headers, json) = successful_get(&harness, path, None, None).await; @@ -622,14 +622,14 @@ async fn spatial_representations_validate_and_keep_distinct_cache_identities() { successful_get(&harness, path, Some("application/ld+json"), None).await; let (rfc_headers, rfc) = successful_get( &harness, - &format!("{path}?profile=rfc7946"), + &format!("{path}?formatProfile=rfc7946"), Some("application/geo+json"), None, ) .await; let (json_fg_headers, json_fg) = successful_get( &harness, - &format!("{path}?profile=jsonfg"), + &format!("{path}?formatProfile=jsonfg"), Some("application/geo+json"), None, ) @@ -640,7 +640,7 @@ async fn spatial_representations_validate_and_keep_distinct_cache_identities() { &harness .artifacts .get( - "artifacts/registered-premises--read--representation-public-premises.context.jsonld", + "artifacts/registered-premises--read--access-profile-public-premises.context.jsonld", ) .expect("generated spatial JSON-LD context") .content, @@ -649,7 +649,7 @@ async fn spatial_representations_validate_and_keep_distinct_cache_identities() { assert_eq!( json_ld.get("@context").and_then(Value::as_str), Some( - "https://business.example.invalid/v2/artifacts/registered-premises--read--representation-public-premises-context", + "https://business.example.invalid/v2/artifacts/registered-premises--read--access-profile-public-premises-context", ) ); assert_eq!( @@ -665,7 +665,7 @@ async fn spatial_representations_validate_and_keep_distinct_cache_identities() { &harness .artifacts .get( - "artifacts/registered-premises--read--representation-public-premises.geojson.schema.json", + "artifacts/registered-premises--read--access-profile-public-premises.geojson.schema.json", ) .expect("generated spatial response schema") .content, @@ -709,10 +709,10 @@ async fn spatial_representations_validate_and_keep_distinct_cache_identities() { headers .get(ETAG) .and_then(|value| value.to_str().ok()) - .expect("snapshot representation has an ETag") + .expect("snapshot format has an ETag") }) .collect::>(); - assert_eq!(etags.len(), 4, "each exact representation has its own ETag"); + assert_eq!(etags.len(), 4, "each exact format has its own ETag"); let json_fg_etag = json_fg_headers .get(ETAG) @@ -722,7 +722,7 @@ async fn spatial_representations_validate_and_keep_distinct_cache_identities() { .app .clone() .oneshot(get_request( - &format!("{path}?profile=jsonfg"), + &format!("{path}?formatProfile=jsonfg"), Some("application/geo+json"), Some(json_fg_etag), )) @@ -760,7 +760,7 @@ async fn spatial_terminal_audit_failure_discards_held_feature_bytes() { let response = harness .app .oneshot(get_request( - "/v2/resources/registered-premises/records/PREM-SYNTH-0001?profile=jsonfg", + "/v2/resources/registered-premises/records/PREM-SYNTH-0001?formatProfile=jsonfg", Some("application/geo+json"), None, )) @@ -1010,7 +1010,7 @@ async fn operation_bound_metadata_is_no_store_and_links_only_visible_artifacts() capability["processingReference"] .as_str() .is_some_and(|reference| reference.ends_with( - "/v2/artifacts/assistance-enrolment--lookup-by-case-and-person--representation-limited-processing" + "/v2/artifacts/assistance-enrolment--lookup-by-case-and-person--access-profile-limited-processing" )), "processing metadata link resolves to the mounted artifact identifier" ); @@ -1105,6 +1105,126 @@ async fn invalid_bearer_on_unknown_data_routes_is_audited_fail_closed() { assert_eq!(failing_sink.writes(), 1); } +#[tokio::test] +async fn invalid_bearer_precedes_named_search_resolution() { + let sink = Arc::new(ControlledAuditSink::new(usize::MAX)); + let harness = ProjectHarness::open_with_audit( + "business-registry", + Some(Arc::clone(&sink) as Arc), + ) + .await; + for uri in [ + "/v2/resources/registered-premises/searches/within-bbox?bbox=100,13,101,14", + "/v2/resources/registered-premises/searches/unknown?bbox=100,13,101,14", + ] { + assert_problem_code( + harness + .app + .clone() + .oneshot( + Request::builder() + .uri(uri) + .header(AUTHORIZATION, "Bearer malformed") + .body(Body::empty()) + .expect("search request builds"), + ) + .await + .expect("router responds"), + StatusCode::UNAUTHORIZED, + "auth.invalid_credential", + ) + .await; + } + let records = sink.values(); + assert_eq!(records.len(), 2); + for record in &records { + assert_eq!(record["phase"], "refusal"); + assert_eq!(record["outcome"], "invalid-credential"); + assert!(record.get("resourceIdentifier").is_none()); + assert!(record.get("operationIdentifier").is_none()); + assert!(record.get("accessProfile").is_none()); + } + let wire = serde_json::to_string(&records).expect("audit serializes"); + for hidden in [ + "within-bbox", + "searches/unknown", + "100,13,101,14", + "malformed", + ] { + assert!(!wire.contains(hidden)); + } +} + +#[tokio::test] +async fn bbox_shape_refusals_are_audited_before_any_search_attempt() { + let sink = Arc::new(ControlledAuditSink::new(usize::MAX)); + let harness = ProjectHarness::open_with_audit( + "business-registry", + Some(Arc::clone(&sink) as Arc), + ) + .await; + for (uri, code) in [ + ( + "/v2/resources/registered-premises/searches/within-bbox", + "filter.invalid_value", + ), + ( + "/v2/resources/registered-premises/searches/within-bbox?bbox=hidden,bbox,canary", + "filter.invalid_value", + ), + ] { + assert_problem_code( + harness + .app + .clone() + .oneshot( + Request::builder() + .uri(uri) + .body(Body::empty()) + .expect("request builds"), + ) + .await + .expect("router responds"), + StatusCode::BAD_REQUEST, + code, + ) + .await; + } + let journey = project_journey("business-registry"); + let list_fixture = journey + .authorizations + .get("premises-list") + .expect("premises-list fixture"); + let list_token = harness.token("premises-list", list_fixture); + assert_problem_code( + harness + .app + .clone() + .oneshot( + Request::builder() + .uri("/v2/resources/registered-premises/records?bbox=100,13,101,14") + .header(AUTHORIZATION, format!("Bearer {list_token}")) + .body(Body::empty()) + .expect("list request builds"), + ) + .await + .expect("router responds"), + StatusCode::BAD_REQUEST, + "filter.unknown_field", + ) + .await; + let records = sink.values(); + assert_eq!(records.len(), 3); + assert!(records.iter().all(|record| record["phase"] == "refusal")); + assert!(records + .iter() + .all(|record| record["selectedProperties"] == json!([]))); + let wire = serde_json::to_string(&records).expect("audit serializes"); + for hidden in ["hidden,bbox,canary", "100,13,101,14"] { + assert!(!wire.contains(hidden)); + } +} + #[tokio::test] async fn insufficient_scope_and_unknown_data_surfaces_are_indistinguishable() { let sink = Arc::new(ControlledAuditSink::new(usize::MAX)); @@ -1640,7 +1760,7 @@ fn assert_geojson_expectations( } } } - let Some(profile) = expectation.representation_profile else { + let Some(profile) = expectation.format_profile else { return; }; assert_eq!( @@ -1651,10 +1771,8 @@ fn assert_geojson_expectations( "{label} GeoJSON content type" ); let (uri, conforms_to) = match profile { - JourneyRepresentationProfile::Rfc7946 => { - ("http://www.opengis.net/def/profile/OGC/0/rfc7946", None) - } - JourneyRepresentationProfile::JsonFg => ( + JourneyFormatProfile::Rfc7946 => ("http://www.opengis.net/def/profile/OGC/0/rfc7946", None), + JourneyFormatProfile::JsonFg => ( "http://www.opengis.net/def/profile/OGC/0/jsonfg", Some(BTreeSet::from([ "http://www.opengis.net/spec/json-fg-1/1.0/conf/core", @@ -1778,10 +1896,10 @@ fn validate_response_contracts( .pointer("/meta/operationIdentifier") .and_then(Value::as_str) .expect("Record response names its compiled operation"); - let representation_identifier = document - .pointer("/meta/representation") + let access_profile_identifier = document + .pointer("/meta/accessProfile") .and_then(Value::as_str) - .expect("Record response names its selected representation"); + .expect("Record response names its selected access profile"); let matching_bindings = harness .service .artifacts @@ -1789,13 +1907,13 @@ fn validate_response_contracts( .iter() .filter(|binding| { binding.operation_identifier == operation_identifier - && binding.representation_identifier == representation_identifier + && binding.access_profile_identifier == access_profile_identifier }) .collect::>(); assert_eq!( matching_bindings.len(), 1, - "{project}/{} must resolve one exact operation and representation binding", + "{project}/{} must resolve one exact operation and access-profile binding", step.id ); let binding = matching_bindings[0]; @@ -1804,7 +1922,7 @@ fn validate_response_contracts( let schema_reference = record .get("schemaReference") .and_then(Value::as_str) - .expect("Record carries its exact permitted-representation schema reference"); + .expect("Record carries its exact permitted-access-profile schema reference"); assert_eq!( document .pointer("/meta/links/schema") @@ -1829,7 +1947,7 @@ fn validate_response_contracts( assert_eq!( matching_schemas.len(), 1, - "{project}/{} must resolve exactly one generated permitted-representation schema", + "{project}/{} must resolve exactly one generated permitted-access-profile schema", step.id ); let (schema_artifact, schema) = &matching_schemas[0]; @@ -1839,22 +1957,22 @@ fn validate_response_contracts( .compile(schema) .unwrap_or_else(|_| { panic!( - "{project}/{} generated permitted-representation schema must compile", + "{project}/{} generated permitted-access-profile schema must compile", step.id ) }); assert!( validator.is_valid(record), - "{project}/{} Record must validate against its exact generated permitted-representation schema", + "{project}/{} Record must validate against its exact generated permitted-access-profile schema", step.id ); assert_eq!( - binding.representation_schema_path, schema_artifact.path, - "{project}/{} schema must belong to the exact operation and representation", + binding.access_profile_schema_path, schema_artifact.path, + "{project}/{} schema must belong to the exact operation and access profile", step.id ); - let shacl_path = &binding.representation_shacl_path; + let shacl_path = &binding.access_profile_shacl_path; let shacl_artifact = harness .service .artifacts @@ -1898,29 +2016,29 @@ fn validate_json_ld_graph( .any(|operation| operation.identifier == binding.operation_identifier) }) .expect("compiled operation belongs to one resource"); - let representation = resource + let access_profile = resource .operations .iter() .find(|operation| operation.identifier == binding.operation_identifier) .and_then(|operation| { operation - .representations + .access_profiles .iter() - .find(|representation| representation.id == binding.representation_identifier) + .find(|access_profile| access_profile.id == binding.access_profile_identifier) }) - .expect("compiled operation carries the selected representation"); + .expect("compiled operation carries the selected access profile"); assert_eq!( document.get("@context").and_then(Value::as_str), - Some(representation.context_reference.as_str()), - "{project}/{} JSON-LD response must name the selected representation context", + Some(access_profile.context_reference.as_str()), + "{project}/{} JSON-LD response must name the selected access profile context", step.id ); assert_eq!( document .pointer("/meta/links/context") .and_then(Value::as_str), - Some(representation.context_reference.as_str()), - "{project}/{} response metadata must name the selected representation context", + Some(access_profile.context_reference.as_str()), + "{project}/{} response metadata must name the selected access profile context", step.id ); let context_artifact = harness @@ -1958,7 +2076,7 @@ fn validate_json_ld_graph( &harness .service .artifacts - .get(&binding.representation_shacl_path) + .get(&binding.access_profile_shacl_path) .expect("bound SHACL artifact exists") .content, ) @@ -2024,7 +2142,7 @@ fn validate_json_ld_graph( .as_ref() .filter(|geometry| geometry.name == *property_name) { - assert!(representation.selectable_properties.contains(property_name)); + assert!(access_profile.selectable_properties.contains(property_name)); let datatype = "http://www.w3.org/1999/02/22-rdf-syntax-ns#JSON"; assert_typed_quad( &quads, @@ -2045,7 +2163,7 @@ fn validate_json_ld_graph( .iter() .find(|property| property.name == *property_name) .expect("disclosed property is compiled"); - assert!(representation.selectable_properties.contains(property_name)); + assert!(access_profile.selectable_properties.contains(property_name)); let datatype = registry_relay_v2::semantics::datatype_iri(property.data_type); assert_typed_quad( &quads, diff --git a/crates/registry-relay-v2/tests/representation_http.rs b/crates/registry-relay-v2/tests/access_profile_http.rs similarity index 90% rename from crates/registry-relay-v2/tests/representation_http.rs rename to crates/registry-relay-v2/tests/access_profile_http.rs index a2d435f0a..0585ad425 100644 --- a/crates/registry-relay-v2/tests/representation_http.rs +++ b/crates/registry-relay-v2/tests/access_profile_http.rs @@ -25,11 +25,11 @@ use registry_relay_v2::contract::{ }; use registry_relay_v2::cursor::CursorKey; use registry_relay_v2::model::{ - CapabilityFamily, CompiledAccess, CompiledCodelist, CompiledDisclosureProfile, - CompiledMetadataVisibility, CompiledOperation, CompiledPagination, CompiledProperty, - CompiledPurpose, CompiledRecordContext, CompiledRegistry, CompiledRepresentation, - CompiledResource, CompiledRowBinding, CompiledSelector, CompiledSource, CompiledTransform, - ConsultationPattern, EffectiveClassification, OperationKind, QueryPlan, RowAuthoritySource, + CapabilityFamily, CompiledAccess, CompiledAccessProfile, CompiledCodelist, + CompiledDisclosureProfile, CompiledMetadataVisibility, CompiledOperation, CompiledPagination, + CompiledProperty, CompiledPurpose, CompiledRecordContext, CompiledRegistry, CompiledResource, + CompiledRowBinding, CompiledSelector, CompiledSource, CompiledTransform, ConsultationPattern, + EffectiveClassification, OperationKind, QueryPlan, RowAuthoritySource, }; use registry_relay_v2::server::{ router, InstitutionMetadata, QuotaConfig, RelayService, ServiceMetadata, @@ -41,7 +41,7 @@ use tower::ServiceExt as _; const SOURCE: &str = "source"; const RESOURCE: &str = "record"; -const AUDIENCE: &str = "urn:example:relay:representations"; +const AUDIENCE: &str = "urn:example:relay:access_profiles"; const FIXTURE_SQL: &str = r#" CREATE TABLE source_records ( @@ -220,7 +220,7 @@ impl Harness { name: "Example Authority".into(), }, operator: None, - authoritative_scope: "Synthetic representation tests".into(), + authoritative_scope: "Synthetic access-profile tests".into(), alignment_targets: Vec::new(), }, )); @@ -308,7 +308,7 @@ impl Harness { } #[tokio::test] -async fn representation_selection_authenticates_then_authorizes_the_exact_profile() { +async fn access_profile_selection_authenticates_then_authorizes_the_exact_profile() { let sink = Arc::new(RecordingSink::default()); let harness = Harness::open(None, Arc::clone(&sink)).await; let limited = harness.token(&["registry:limited"], "review", "area-a"); @@ -316,7 +316,7 @@ async fn representation_selection_authenticates_then_authorizes_the_exact_profil let (status, _, body) = harness .send( Method::GET, - "/v2/resources/record/records/record-1?representation=missing", + "/v2/resources/record/records/record-1?accessProfile=missing", Some("not-a-jwt"), None, &[], @@ -329,7 +329,7 @@ async fn representation_selection_authenticates_then_authorizes_the_exact_profil "auth.invalid_credential", ); - for (token, representation, expected_status, expected_code) in [ + for (token, access_profile, expected_status, expected_code) in [ ( None, "caseworker", @@ -350,7 +350,7 @@ async fn representation_selection_authenticates_then_authorizes_the_exact_profil "resource.not_found", ), ] { - let uri = format!("/v2/resources/record/records/record-1?representation={representation}"); + let uri = format!("/v2/resources/record/records/record-1?accessProfile={access_profile}"); let (status, _, body) = harness.send(Method::GET, &uri, token, None, &[]).await; assert_problem(status, &body, expected_status, expected_code); } @@ -359,11 +359,11 @@ async fn representation_selection_authenticates_then_authorizes_the_exact_profil assert!(records.iter().all(|event| event["phase"] == "refusal")); assert!(records .iter() - .all(|event| event.get("representation").is_none())); + .all(|event| event.get("accessProfile").is_none())); assert_eq!( records .iter() - .filter(|event| event.get("representation").is_none()) + .filter(|event| event.get("accessProfile").is_none()) .count(), 5 ); @@ -372,14 +372,14 @@ async fn representation_selection_authenticates_then_authorizes_the_exact_profil } #[tokio::test] -async fn oversized_uri_still_conceals_exact_representation_authorization() { +async fn oversized_uri_still_conceals_exact_access_profile_authorization() { let harness = Harness::open(None, Arc::new(RecordingSink::default())).await; let limited = harness.token(&["registry:limited"], "review", "area-a"); let padding = "x".repeat(20_000); - for representation in ["caseworker", "missing"] { + for access_profile in ["caseworker", "missing"] { let uri = format!( - "/v2/resources/record/records/record-1?representation={representation}&padding={padding}" + "/v2/resources/record/records/record-1?accessProfile={access_profile}&padding={padding}" ); let (status, _, body) = harness .send(Method::GET, &uri, Some(&limited), None, &[]) @@ -398,22 +398,22 @@ async fn preflight_refusals_do_not_reach_source_and_attempt_audit_precedes_sourc .expect("test source moves after runtime open"); for (uri, expected_status, code) in [ ( - "/v2/resources/record/records?representation=", + "/v2/resources/record/records?accessProfile=", StatusCode::BAD_REQUEST, - "request.representation_invalid", + "request.access_profile_invalid", ), ( - "/v2/resources/record/records?representation=limited&representation=caseworker", + "/v2/resources/record/records?accessProfile=limited&accessProfile=caseworker", StatusCode::BAD_REQUEST, - "request.representation_invalid", + "request.access_profile_invalid", ), ( - "/v2/resources/record/records?representation=missing", + "/v2/resources/record/records?accessProfile=missing", StatusCode::NOT_FOUND, "resource.not_found", ), ( - "/v2/resources/record/records?representation=limited&fields=secretValue", + "/v2/resources/record/records?accessProfile=limited&fields=secretValue", StatusCode::BAD_REQUEST, "request.fields_invalid", ), @@ -435,7 +435,7 @@ async fn preflight_refusals_do_not_reach_source_and_attempt_audit_precedes_sourc let (status, _, body) = harness .send( Method::GET, - "/v2/resources/record/records/record-1?representation=limited", + "/v2/resources/record/records/record-1?accessProfile=limited", Some(&limited), None, &[], @@ -459,13 +459,13 @@ async fn preflight_refusals_do_not_reach_source_and_attempt_audit_precedes_sourc } #[tokio::test] -async fn fields_only_minimize_the_selected_representation() { +async fn fields_only_minimize_the_selected_access_profile() { let harness = Harness::open(None, Arc::new(RecordingSink::default())).await; let limited = harness.token(&["registry:limited"], "review", "area-a"); let (status, _, body) = harness .send( Method::POST, - "/v2/resources/record/lookups/by-key?representation=limited&fields=maskedSecret", + "/v2/resources/record/lookups/by-key?accessProfile=limited&fields=maskedSecret", Some(&limited), Some(json!({"selectors": {"lookupKey": "lookup-2"}})), &[], @@ -477,13 +477,13 @@ async fn fields_only_minimize_the_selected_representation() { document["data"]["domainData"], json!({"maskedSecret": "***CDEF"}) ); - assert_eq!(document["meta"]["representation"], "limited"); + assert_eq!(document["meta"]["accessProfile"], "limited"); assert_eq!(document["meta"]["selectedFields"], json!(["maskedSecret"])); let (status, _, body) = harness .send( Method::GET, - "/v2/resources/record/records/record-1?representation=limited&fields=secretValue", + "/v2/resources/record/records/record-1?accessProfile=limited&fields=secretValue", Some(&limited), None, &[], @@ -498,7 +498,7 @@ async fn fields_only_minimize_the_selected_representation() { } #[tokio::test] -async fn cursor_and_etag_are_bound_to_selected_representation() { +async fn cursor_and_etag_are_bound_to_selected_access_profile() { let harness = Harness::open(None, Arc::new(RecordingSink::default())).await; let all = harness.token( &["registry:limited", "registry:caseworker"], @@ -523,7 +523,7 @@ async fn cursor_and_etag_are_bound_to_selected_representation() { let (status, headers, _) = harness .send( Method::GET, - "/v2/resources/record/records/record-1?representation=public", + "/v2/resources/record/records/record-1?accessProfile=public", None, None, &[(IF_NONE_MATCH.as_str(), &etag)], @@ -539,7 +539,7 @@ async fn cursor_and_etag_are_bound_to_selected_representation() { let (status, headers, body) = harness .send( Method::GET, - "/v2/resources/record/records?representation=limited&pageSize=1", + "/v2/resources/record/records?accessProfile=limited&pageSize=1", Some(&all), None, &[], @@ -557,7 +557,7 @@ async fn cursor_and_etag_are_bound_to_selected_representation() { let cursor = document["pageInfo"]["nextCursor"] .as_str() .expect("limited cursor"); - let uri = format!("/v2/resources/record/records?representation=caseworker&cursor={cursor}"); + let uri = format!("/v2/resources/record/records?accessProfile=caseworker&cursor={cursor}"); let (status, _, body) = harness.send(Method::GET, &uri, Some(&all), None, &[]).await; assert_problem( status, @@ -568,7 +568,7 @@ async fn cursor_and_etag_are_bound_to_selected_representation() { } #[tokio::test] -async fn metadata_and_artifacts_authorize_each_representation_exactly() { +async fn metadata_and_artifacts_authorize_each_access_profile_exactly() { let harness = Harness::open(None, Arc::new(RecordingSink::default())).await; let (status, _, body) = harness.send(Method::GET, "/v2", None, None, &[]).await; assert_eq!(status, StatusCode::OK); @@ -581,8 +581,8 @@ async fn metadata_and_artifacts_authorize_each_representation_exactly() { .artifacts .artifacts .iter() - .find(|artifact| artifact.representation_identifier.as_deref() == Some("limited")) - .expect("limited representation artifact"); + .find(|artifact| artifact.access_profile_identifier.as_deref() == Some("limited")) + .expect("limited access-profile artifact"); let path = format!("/v2/artifacts/{}", limited_artifact.id); let caseworker = harness.token(&["registry:caseworker"], "review", "area-a"); let (status, _, _) = harness @@ -669,7 +669,7 @@ async fn transforms_are_bounded_value_free_and_terminal_audit_gates_exact_bytes( let (status, _, body) = harness .send( Method::GET, - "/v2/resources/record/records/record-1?representation=limited", + "/v2/resources/record/records/record-1?accessProfile=limited", Some(&limited), None, &[], @@ -691,7 +691,7 @@ async fn transforms_are_bounded_value_free_and_terminal_audit_gates_exact_bytes( assert_eq!(correlated.len(), 2); assert_eq!(correlated[0]["operationId"], correlated[1]["operationId"]); for record in &correlated { - assert_eq!(record["representation"], "limited"); + assert_eq!(record["accessProfile"], "limited"); assert_eq!(record["disclosureProfile"], "limited-disclosure"); assert_eq!(record["processingHandling"], "restricted"); assert_eq!(record["disclosureHandling"], "confidential"); @@ -717,7 +717,7 @@ async fn transforms_are_bounded_value_free_and_terminal_audit_gates_exact_bytes( "record-overlong-secret", "record-overlong-date", ] { - let uri = format!("/v2/resources/record/records/{record}?representation=limited"); + let uri = format!("/v2/resources/record/records/{record}?accessProfile=limited"); let (status, _, body) = harness .send(Method::GET, &uri, Some(&limited), None, &[]) .await; @@ -736,7 +736,7 @@ async fn transforms_are_bounded_value_free_and_terminal_audit_gates_exact_bytes( let (status, _, body) = harness .send( Method::POST, - "/v2/resources/record/lookups/by-key?representation=limited", + "/v2/resources/record/lookups/by-key?accessProfile=limited", Some(&limited), Some(json!({"selectors": {"lookupKey": "lookup-3"}})), &[], @@ -757,7 +757,7 @@ async fn transforms_are_bounded_value_free_and_terminal_audit_gates_exact_bytes( let (status, _, body) = failing .send( Method::GET, - "/v2/resources/record/records/record-2?representation=limited", + "/v2/resources/record/records/record-2?accessProfile=limited", Some(&token), None, &[], @@ -775,7 +775,7 @@ async fn transforms_are_bounded_value_free_and_terminal_audit_gates_exact_bytes( } #[tokio::test] -async fn quotas_remain_operation_scoped_across_representations() { +async fn quotas_remain_operation_scoped_across_access_profiles() { let harness = Harness::open( Some(QuotaConfig { requests_per_minute: 1, @@ -792,7 +792,7 @@ async fn quotas_remain_operation_scoped_across_representations() { let (status, _, _) = harness .send( Method::GET, - "/v2/resources/record/records/record-1?representation=public", + "/v2/resources/record/records/record-1?accessProfile=public", None, None, &[], @@ -802,7 +802,7 @@ async fn quotas_remain_operation_scoped_across_representations() { let (status, _, body) = harness .send( Method::GET, - "/v2/resources/record/records/record-1?representation=caseworker", + "/v2/resources/record/records/record-1?accessProfile=caseworker", Some(&all), None, &[], @@ -818,7 +818,7 @@ async fn quotas_remain_operation_scoped_across_representations() { let (status, _, body) = harness .send( Method::GET, - "/v2/resources/record/records/record-1?representation=public", + "/v2/resources/record/records/record-1?accessProfile=public", None, None, &[], @@ -834,7 +834,7 @@ async fn quotas_remain_operation_scoped_across_representations() { let (status, _, body) = harness .send( Method::GET, - "/v2/resources/record/records/record-1?representation=caseworker", + "/v2/resources/record/records/record-1?accessProfile=caseworker", Some(&all), None, &[], @@ -866,7 +866,7 @@ fn assert_problem(actual: StatusCode, body: &[u8], expected: StatusCode, code: & fn compiled_registry(fingerprint: String) -> CompiledRegistry { let core_columns = ["record_id", "revision", "lifecycle", "recorded_at"]; - let public = representation( + let public = access_profile( "public", CompiledAccess::Public, "public-disclosure", @@ -890,7 +890,7 @@ fn compiled_registry(fingerprint: String) -> CompiledRegistry { source_column: "authority".into(), }), }; - let limited = representation( + let limited = access_profile( "limited", protected_access("registry:limited"), "limited-disclosure", @@ -907,7 +907,7 @@ fn compiled_registry(fingerprint: String) -> CompiledRegistry { "maskedOptional=partial-string:suffix:4", ], ); - let caseworker = representation( + let caseworker = access_profile( "caseworker", protected_access("registry:caseworker"), "caseworker-disclosure", @@ -920,14 +920,14 @@ fn compiled_registry(fingerprint: String) -> CompiledRegistry { Handling::Restricted, &[], ); - let representations = vec![public.clone(), limited.clone(), caseworker.clone()]; + let access_profiles = vec![public.clone(), limited.clone(), caseworker.clone()]; let list = CompiledOperation { identifier: "record.list".into(), family: CapabilityFamily::Consultation, pattern: ConsultationPattern::List, kind: OperationKind::List, - default_representation: "public".into(), - representations: representations.clone(), + default_access_profile: "public".into(), + access_profiles: access_profiles.clone(), query: QueryPlan { source: SOURCE.into(), view: "relay_records".into(), @@ -948,8 +948,8 @@ fn compiled_registry(fingerprint: String) -> CompiledRegistry { family: CapabilityFamily::Consultation, pattern: ConsultationPattern::Retrieve, kind: OperationKind::Read, - default_representation: "public".into(), - representations: representations.clone(), + default_access_profile: "public".into(), + access_profiles: access_profiles.clone(), query: QueryPlan { source: SOURCE.into(), view: "relay_records".into(), @@ -969,8 +969,8 @@ fn compiled_registry(fingerprint: String) -> CompiledRegistry { kind: OperationKind::Lookup { name: "by-key".into(), }, - default_representation: "public".into(), - representations, + default_access_profile: "public".into(), + access_profiles, query: QueryPlan { source: SOURCE.into(), view: "relay_records".into(), @@ -992,13 +992,13 @@ fn compiled_registry(fingerprint: String) -> CompiledRegistry { }; CompiledRegistry { contract_revision: "sha256:contract".into(), - contract_id: "representation-tests".into(), + contract_id: "access_profile-tests".into(), contract_version: "1".into(), - registry_identifier: "urn:example:registry:representations".into(), - registry_name: "Representation test Registry".into(), + registry_identifier: "urn:example:registry:access_profiles".into(), + registry_name: "Access profile test Registry".into(), authority_identifier: "urn:example:authority".into(), operator_identifier: None, - authoritative_scope: "Synthetic representation tests".into(), + authoritative_scope: "Synthetic access-profile tests".into(), base_uri: "https://registry.example.invalid/".into(), identifier_lifecycle_policy_ref: "governance/lifecycle.yaml".into(), alignment_targets: Vec::new(), @@ -1072,7 +1072,7 @@ fn compiled_registry(fingerprint: String) -> CompiledRegistry { } #[allow(clippy::too_many_arguments)] -fn representation( +fn access_profile( id: &str, access: CompiledAccess, disclosure_profile: &str, @@ -1081,9 +1081,9 @@ fn representation( processing: Handling, disclosure: Handling, transforms: &[&str], -) -> CompiledRepresentation { +) -> CompiledAccessProfile { let stem = format!("https://registry.example.invalid/artifacts/{id}"); - CompiledRepresentation { + CompiledAccessProfile { id: id.into(), access, disclosure_profile: disclosure_profile.into(), diff --git a/crates/registry-relay-v2/tests/identification.rs b/crates/registry-relay-v2/tests/identification.rs index 411575f7d..470bcc6fb 100644 --- a/crates/registry-relay-v2/tests/identification.rs +++ b/crates/registry-relay-v2/tests/identification.rs @@ -7,10 +7,10 @@ use registry_relay_v2::contract::{ }; use registry_relay_v2::identification::{ classification_inventory_report, classification_review_starter, contextual_review_findings, - core_pack_reference, identification_report_digest, identify_contract, + core_pack_reference, identification_report_digest, identify_contract, operation_explanation, parse_classification_review_yaml, render_classification_inventory_report, render_classification_review_yaml, render_contextual_review_findings, - render_identification_report, render_representation_report, representation_report, + render_identification_report, render_operation_explanation, render_operation_explanation_text, validate_classification_review, CategoricalConfidence, ClassificationReviewExpectation, IdentificationError, IdentificationStatus, TechnicalRole, REVIEWED_IDENTIFICATION_REPORT_PATH, }; @@ -460,9 +460,9 @@ fn review_reports_are_canonical_value_free_and_cover_all_contextual_prompts() { maximum_bytes: Some(32), codelist: None, }); - operation.representations[0].disclosure_handling = + operation.access_profiles[0].disclosure_handling = registry_relay_v2::contract::Handling::Confidential; - operation.representations[0].processing_handling = + operation.access_profiles[0].processing_handling = registry_relay_v2::contract::Handling::Restricted; let inventory_digest = classification_inventory_digest(®istry).expect("inventory digest"); @@ -472,22 +472,23 @@ fn review_reports_are_canonical_value_free_and_cover_all_contextual_prompts() { ); let inventory = classification_inventory_report(®istry, &inventory_digest).expect("inventory"); - let representations = - representation_report(®istry, &inventory_digest).expect("representations"); + let explanation = + operation_explanation(®istry, &inventory_digest).expect("operation explanation"); let findings = contextual_review_findings(®istry, &inventory_digest).expect("findings"); assert_eq!(inventory.classification_inventory_digest, inventory_digest); assert_eq!( - representations.classification_inventory_digest, + explanation.classification_inventory_digest, inventory_digest ); assert_eq!(findings.classification_inventory_digest, inventory_digest); assert_eq!(inventory.resources[0].source_columns.len(), 9); assert_eq!(inventory.resources[0].properties.len(), 6); - let boundary = &representations.resources[0].operations[0].representations[0]; + let boundary = &explanation.operations[0].access_profiles[0]; assert!(boundary - .processed_source_columns + .processing + .source_columns .contains(&"region_code".into())); - assert!(boundary.disclosed_properties.contains(&"notes".into())); + assert!(boundary.disclosure.properties.contains(&"notes".into())); let codes = findings .findings .iter() @@ -511,9 +512,13 @@ fn review_reports_are_canonical_value_free_and_cover_all_contextual_prompts() { render_classification_inventory_report(&inventory).expect("inventory bytes again") ); assert_eq!( - render_representation_report(&representations).expect("representation bytes"), - render_representation_report(&representations).expect("representation bytes again") + render_operation_explanation(&explanation).expect("explanation bytes"), + render_operation_explanation(&explanation).expect("explanation bytes again") ); + let text = render_operation_explanation_text(&explanation); + assert!(text.contains("query capabilities:")); + assert!(text.contains("access profile: default (default)")); + assert!(text.contains("wire formats:")); let finding_bytes = render_contextual_review_findings(&findings).expect("finding bytes"); assert_eq!( finding_bytes, @@ -668,8 +673,8 @@ resources: disclosureProfiles: {default: {properties: [regionCode, categoryCode, personReference, emailPhone, notes]}} operations: read: - defaultRepresentation: default - representations: + defaultAccessProfile: default + accessProfiles: default: {access: public, disclosureProfile: default} processingDescriptions: [] metadataVisibility: {service: public, resources: public, semantics: public, classifications: operator-only, processing: operation-bound} diff --git a/crates/registry-relay-v2/tests/multi_resource_isolation.rs b/crates/registry-relay-v2/tests/multi_resource_isolation.rs index bfd861b1a..90f594c84 100644 --- a/crates/registry-relay-v2/tests/multi_resource_isolation.rs +++ b/crates/registry-relay-v2/tests/multi_resource_isolation.rs @@ -145,16 +145,16 @@ resources: public-view: {properties: [publicLabel]} operations: list: - defaultRepresentation: public - representations: + defaultAccessProfile: public + accessProfiles: public: {access: public, disclosureProfile: public-view} filters: [] allowUnfiltered: true orderBy: [publicIdentifier] pagination: {defaultPageSize: 1, maximumPageSize: 1} read: - defaultRepresentation: public - representations: + defaultAccessProfile: public + accessProfiles: public: {access: public, disclosureProfile: public-view} processingDescriptions: [] - id: protected-unit @@ -190,8 +190,8 @@ resources: protected-view: {properties: [protectedLabel]} operations: list: - defaultRepresentation: protected - representations: + defaultAccessProfile: protected + accessProfiles: protected: access: scope: relay:protected:list @@ -203,8 +203,8 @@ resources: orderBy: [protectedIdentifier] pagination: {defaultPageSize: 2, maximumPageSize: 2} read: - defaultRepresentation: protected - representations: + defaultAccessProfile: protected + accessProfiles: protected: access: scope: relay:protected:read @@ -217,8 +217,8 @@ resources: maximumBytes: 128 selectors: lookupKey: {sourceColumn: lookup_key, type: string, minimumBytes: 1, maximumBytes: 32} - defaultRepresentation: protected - representations: + defaultAccessProfile: protected + accessProfiles: protected: access: scope: relay:protected:lookup @@ -331,13 +331,13 @@ fn compiler_keeps_every_multi_resource_operation_boundary_local() { assert_eq!(list.identifier, format!("{resource_id}.list")); assert_eq!(list.query.source, SOURCE_ID); assert_eq!(list.query.view, view); - let representation = list - .representations + let access_profile = list + .access_profiles .iter() - .find(|representation| representation.id == list.default_representation) - .expect("default representation is compiled"); - assert_eq!(representation.disclosure_profile, disclosure); - assert_eq!(representation.selectable_properties, [field]); + .find(|access_profile| access_profile.id == list.default_access_profile) + .expect("default access_profile is compiled"); + assert_eq!(access_profile.disclosure_profile, disclosure); + assert_eq!(access_profile.selectable_properties, [field]); assert_eq!( list.query .pagination @@ -346,7 +346,7 @@ fn compiler_keeps_every_multi_resource_operation_boundary_local() { .maximum_page_size, page_maximum ); - match (&representation.access, scope, row_column) { + match (&access_profile.access, scope, row_column) { (CompiledAccess::Public, None, None) => {} ( CompiledAccess::Protected { @@ -365,8 +365,8 @@ fn compiler_keeps_every_multi_resource_operation_boundary_local() { } boundary => panic!("unexpected compiled access boundary: {boundary:?}"), } - assert!(representation.schema_reference.contains(resource_id)); - assert!(representation + assert!(access_profile.schema_reference.contains(resource_id)); + assert!(access_profile .semantic_model_reference .contains(resource_id)); } diff --git a/crates/registry-relay-v2/tests/process_http.rs b/crates/registry-relay-v2/tests/process_http.rs index 39849e8f3..8460c3da0 100644 --- a/crates/registry-relay-v2/tests/process_http.rs +++ b/crates/registry-relay-v2/tests/process_http.rs @@ -166,7 +166,10 @@ async fn built_relay_serves_a_sealed_package_over_real_tcp_and_shuts_down() { output_dir: package, }) .expect("sealed package operation succeeds"); - assert!(report.is_success(), "acceptance project packages"); + assert!( + report.is_success(), + "acceptance project packages: {report:?}" + ); let client = Client::builder() .no_proxy() @@ -188,17 +191,42 @@ fn make_business_project_public_only(project: &Path, source: &Path) { &fs::read_to_string(&contract_path).expect("business contract reads"), ) .expect("business contract becomes a value"); - for pointer in [ - "/resources/0/operations/list/representations", - "/resources/0/operations/read/representations", + for (pointer, profile) in [ + ("/resources/0/operations/list/accessProfiles", "registrar"), + ("/resources/0/operations/read/accessProfiles", "registrar"), + ( + "/resources/1/operations/read/accessProfiles", + "registrar-premises", + ), + ( + "/resources/1/operations/searches/0/accessProfiles", + "registrar-premises", + ), ] { value .pointer_mut(pointer) .and_then(Value::as_object_mut) - .expect("business representation map") - .remove("registrar") - .expect("registrar representation exists"); + .expect("business access-profile map") + .remove(profile) + .expect("protected access profile exists"); } + let premises_list = value + .pointer_mut("/resources/1/operations/list") + .and_then(Value::as_object_mut) + .expect("premises list operation"); + premises_list.insert( + "defaultAccessProfile".into(), + Value::String("public-premises".into()), + ); + premises_list.insert( + "accessProfiles".into(), + serde_json::json!({ + "public-premises": { + "access": "public", + "disclosureProfile": "public-premises" + } + }), + ); fs::write( &contract_path, serde_norway::to_string(&value).expect("public-only contract serializes"), diff --git a/crates/registry-relayctl/INTEGRATION.md b/crates/registry-relayctl/INTEGRATION.md index 93a8aedc2..3d7b9ab45 100644 --- a/crates/registry-relayctl/INTEGRATION.md +++ b/crates/registry-relayctl/INTEGRATION.md @@ -13,7 +13,9 @@ The shared facade must provide: - `inspect_schema(&InspectOptions)` through the Relay wrapper over `registry-platform-sqlite`, returning structural metadata only; - `check_project(&CheckOptions)`, including a production profile that refuses - every unreviewed suggestion; + every unreviewed suggestion and an opt-in explanation of the exact compiled + operation, access, disclosure, processing, transform, query, and wire-format + boundaries; - `generate_project`, `test_project`, `diff_projects`, and `package_project` using the exact compiler, fixture, diff, and packager implementations shared with the runtime; @@ -29,6 +31,14 @@ fingerprint. It must never contain row values, defaults evaluated from rows, or SQL query results. `ToolingDetails::Diff` is the compiler's authoritative change report. The CLI neither adds nor removes change classes. +`relayctl check PROJECT --explain` remains read-only and compiles through that +same facade once. A successful check includes the canonical typed operation +explanation. A refused check includes the existing diagnostics and no partial +explanation. `relayctl generate` writes the same canonical explanation to +`generated/reports/operation-explanation.json` by default, or to the same +relative report path beneath `--output`. The CLI only renders it for people or +serializes the shared report for automation. + Workspace integration adds `registry-relay-v2` and `registry-relayctl` as root members and workspace dependencies. That root edit and the corresponding lock update are intentionally outside this crate's ownership. diff --git a/crates/registry-relayctl/src/lib.rs b/crates/registry-relayctl/src/lib.rs index 825508ce9..427cfea6e 100644 --- a/crates/registry-relayctl/src/lib.rs +++ b/crates/registry-relayctl/src/lib.rs @@ -10,6 +10,8 @@ use std::io::{self, Write}; use std::process::ExitCode; use clap::{Args, Parser, Subcommand}; +use registry_relay_v2::identification::render_operation_explanation_text; +use registry_relay_v2::tooling::{ToolingDetails, ToolingReport}; use serde::Serialize; mod shared; @@ -78,6 +80,10 @@ struct CheckArgs { /// Require all generated suggestions to have been reviewed. #[arg(long)] production: bool, + + /// Explain compiled operations, access, disclosure, and wire formats. + #[arg(long)] + explain: bool, } #[derive(Debug, Args)] @@ -163,7 +169,7 @@ where } }; - if render_report(command_name, &report, cli.json, stdout).is_err() { + if render_tooling_report(command_name, &report, cli.json, stdout).is_err() { let _ = writeln!(stderr, "relayctl: output could not be written"); return ExitCode::from(OPERATIONAL_FAILURE_EXIT); } @@ -189,27 +195,100 @@ impl Command { } } -fn render_report( +fn render_tooling_report( command: &str, - report: &T, + report: &ToolingReport, json: bool, output: &mut dyn Write, ) -> io::Result<()> { if json { - serde_json::to_writer_pretty(&mut *output, report).map_err(io::Error::other)?; - writeln!(output) - } else { - writeln!(output, "relayctl {command}")?; - // The shared report is the sole source of command details. Rendering - // it here does not reinterpret compiler outcomes or change classes. - serde_json::to_writer_pretty(&mut *output, report).map_err(io::Error::other)?; - writeln!(output) + return render_json(report, output); } + + writeln!(output, "relayctl {command}")?; + if let ToolingDetails::Check { + operation_explanation: Some(explanation), + .. + } = &report.details + { + return output.write_all(render_operation_explanation_text(explanation).as_bytes()); + } + + // The shared report is the sole source of command details. Rendering it + // here does not reinterpret compiler outcomes or change classes. + render_json(report, output) +} + +fn render_json(report: &T, output: &mut dyn Write) -> io::Result<()> { + serde_json::to_writer_pretty(&mut *output, report).map_err(io::Error::other)?; + writeln!(output) } #[cfg(test)] mod tests { use super::*; + use std::sync::atomic::{AtomicU64, Ordering}; + use std::time::{SystemTime, UNIX_EPOCH}; + + static TEMPORARY_DIRECTORY_SEQUENCE: AtomicU64 = AtomicU64::new(0); + + struct TemporaryDirectory(std::path::PathBuf); + + impl TemporaryDirectory { + fn create() -> Self { + let sequence = TEMPORARY_DIRECTORY_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock follows the Unix epoch") + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "registry-relayctl-unit-{}-{timestamp}-{sequence}", + std::process::id() + )); + std::fs::create_dir(&path).expect("temporary project root creates"); + Self(path) + } + + fn path(&self) -> &std::path::Path { + &self.0 + } + } + + impl Drop for TemporaryDirectory { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + fn generic_project() -> (TemporaryDirectory, std::path::PathBuf) { + let temporary = TemporaryDirectory::create(); + let project = temporary.path().join("project"); + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let status = run_from( + [ + OsString::from("relayctl"), + OsString::from("init"), + project.clone().into_os_string(), + ], + &mut stdout, + &mut stderr, + ); + assert_eq!( + status, + ExitCode::SUCCESS, + "{}", + String::from_utf8_lossy(&stderr) + ); + std::fs::remove_file(project.join("runtime.yaml")) + .expect("runtime is optional for an authoring check"); + std::fs::write( + project.join("fixture.sql"), + "-- ROW-VALUE-CANARY REQUEST-VALUE-CANARY PRINCIPAL-VALUE-CANARY\n", + ) + .expect("generic value canaries write"); + (temporary, project) + } #[test] fn every_approved_command_is_present() { @@ -283,13 +362,11 @@ mod tests { } let mut output = Vec::new(); - render_report( - "inspect", + render_json( &Report { status: "accepted", summary: "schema structure inspected", }, - true, &mut output, ) .expect("report renders"); @@ -308,6 +385,25 @@ mod tests { assert!(args.production); } + #[test] + fn operation_explanation_is_an_explicit_check_mode() { + let cli = + Cli::try_parse_from(["relayctl", "check", "project", "--explain", "--production"]) + .expect("explanation check parses"); + let Command::Check(args) = cli.command else { + panic!("check command is retained"); + }; + assert!(args.explain); + assert!(args.production); + } + + #[test] + fn operation_explanation_is_not_a_mode_of_write_commands() { + let error = Cli::try_parse_from(["relayctl", "generate", "project", "--explain"]) + .expect_err("explanation is confined to check"); + assert_eq!(error.kind(), clap::error::ErrorKind::UnknownArgument); + } + #[test] fn json_rendering_is_deterministic_and_has_one_trailing_newline() { #[derive(Serialize)] @@ -322,11 +418,59 @@ mod tests { status: "accepted", summary: "schema structure inspected", }; - render_report("inspect", &report, true, &mut first).expect("report renders"); - render_report("inspect", &report, true, &mut second).expect("report repeats"); + render_json(&report, &mut first).expect("report renders"); + render_json(&report, &mut second).expect("report repeats"); assert_eq!(first, second); assert!(first.ends_with(b"\n")); assert!(!first.ends_with(b"\n\n")); } + + #[test] + fn human_explanation_is_grouped_and_does_not_dump_key_paths() { + let (_temporary, project) = generic_project(); + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + + let status = run_from( + [ + OsString::from("relayctl"), + OsString::from("check"), + project.into_os_string(), + OsString::from("--explain"), + ], + &mut stdout, + &mut stderr, + ); + + assert_eq!( + status, + ExitCode::SUCCESS, + "{}", + String::from_utf8_lossy(&stderr) + ); + assert!(stderr.is_empty()); + let rendered = String::from_utf8(stdout).expect("human output is UTF-8"); + for heading in [ + "Resource: ", + "consultation/", + "query capabilities:", + "access profile:", + "processing:", + "disclosure:", + "wire formats:", + ] { + assert!(rendered.contains(heading), "missing heading {heading}"); + } + assert!(!rendered.contains("configurationKeyPaths")); + for canary in [ + "ROW-VALUE-CANARY", + "REQUEST-VALUE-CANARY", + "PRINCIPAL-VALUE-CANARY", + ] { + assert!(!rendered.contains(canary)); + } + assert!(rendered.ends_with('\n')); + assert!(!rendered.ends_with("\n\n")); + } } diff --git a/crates/registry-relayctl/src/shared.rs b/crates/registry-relayctl/src/shared.rs index 57a349d13..04004d22c 100644 --- a/crates/registry-relayctl/src/shared.rs +++ b/crates/registry-relayctl/src/shared.rs @@ -20,6 +20,7 @@ pub(crate) fn execute(command: Command) -> Result { Command::Check(args) => tooling::check_project(&CheckOptions { project_root: args.project, production: args.production, + explain: args.explain, }), Command::Generate(args) => tooling::generate_project(&GenerateOptions { project_root: args.project, diff --git a/crates/registry-relayctl/tests/cli_contract.rs b/crates/registry-relayctl/tests/cli_contract.rs index 8a04f8558..1e18d6aa4 100644 --- a/crates/registry-relayctl/tests/cli_contract.rs +++ b/crates/registry-relayctl/tests/cli_contract.rs @@ -1,6 +1,38 @@ // SPDX-License-Identifier: Apache-2.0 use std::process::Command; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +static TEMPORARY_DIRECTORY_SEQUENCE: AtomicU64 = AtomicU64::new(0); + +struct TemporaryDirectory(std::path::PathBuf); + +impl TemporaryDirectory { + fn create() -> Self { + let sequence = TEMPORARY_DIRECTORY_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock follows the Unix epoch") + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "registry-relayctl-integration-{}-{timestamp}-{sequence}", + std::process::id() + )); + std::fs::create_dir(&path).expect("temporary project root creates"); + Self(path) + } + + fn path(&self) -> &std::path::Path { + &self.0 + } +} + +impl Drop for TemporaryDirectory { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} fn relayctl(arguments: &[&str]) -> std::process::Output { Command::new(env!("CARGO_BIN_EXE_relayctl")) @@ -9,6 +41,26 @@ fn relayctl(arguments: &[&str]) -> std::process::Output { .expect("relayctl starts") } +fn generic_project() -> (TemporaryDirectory, std::path::PathBuf) { + let temporary = TemporaryDirectory::create(); + let project = temporary.path().join("project"); + let project_text = project.to_str().expect("project path is UTF-8"); + let initialized = relayctl(&["init", project_text]); + assert!( + initialized.status.success(), + "{}", + String::from_utf8_lossy(&initialized.stderr) + ); + std::fs::remove_file(project.join("runtime.yaml")) + .expect("runtime is optional for an authoring check"); + std::fs::write( + project.join("fixture.sql"), + "-- ROW-VALUE-CANARY REQUEST-VALUE-CANARY PRINCIPAL-VALUE-CANARY\n", + ) + .expect("generic value canaries write"); + (temporary, project) +} + #[test] fn the_adopter_workflow_is_exposed_by_one_binary() { for command in [ @@ -59,3 +111,45 @@ fn adopter_commands_link_the_shared_library_and_never_spawn_relay() { ); } } + +#[test] +fn json_explanation_is_one_value_free_document_and_plain_check_omits_it() { + let (_temporary, project) = generic_project(); + assert!(!project.join("fixture.sqlite").exists()); + assert!(!project.join("generated").exists()); + let project_text = project.to_str().expect("project path is UTF-8"); + + let explained = relayctl(&["--json", "check", project_text, "--explain"]); + assert!( + explained.status.success(), + "{}", + String::from_utf8_lossy(&explained.stderr) + ); + assert!(explained.stderr.is_empty()); + let explanation: serde_json::Value = + serde_json::from_slice(&explained.stdout).expect("one valid JSON document"); + assert_eq!(explanation["status"], "success"); + assert_eq!( + explanation["details"]["operation_explanation"]["kind"], + "OperationExplanation" + ); + let rendered = String::from_utf8(explained.stdout).expect("report is UTF-8"); + for canary in [ + "ROW-VALUE-CANARY", + "REQUEST-VALUE-CANARY", + "PRINCIPAL-VALUE-CANARY", + ] { + assert!(!rendered.contains(canary), "report leaked fixture value"); + } + assert!(rendered.ends_with('\n')); + assert!(!rendered.ends_with("\n\n")); + + let plain = relayctl(&["check", project_text, "--json"]); + assert!(plain.status.success()); + let plain: serde_json::Value = + serde_json::from_slice(&plain.stdout).expect("plain check is valid JSON"); + assert!(plain["details"].get("operation_explanation").is_none()); + + assert!(!project.join("fixture.sqlite").exists()); + assert!(!project.join("generated").exists()); +} diff --git a/products/relay-v2/CONCEPT.md b/products/relay-v2/CONCEPT.md index 120d37f33..b0a3fd110 100644 --- a/products/relay-v2/CONCEPT.md +++ b/products/relay-v2/CONCEPT.md @@ -67,7 +67,7 @@ The existing `registryctl` is not renamed, migrated, deprecated, or otherwise ch The intended authoring lifecycle is: ```text -init -> inspect -> check -> generate -> test -> diff -> package +init -> inspect -> check --explain -> generate -> test -> diff -> package ``` Inspection is schema-only by default. Any future value sampling must be explicit, local, bounded, and value-free in its output. Generated semantics and classifications are suggestions until reviewed. @@ -223,7 +223,7 @@ for serving a valid Registry or a runtime policy input. DPV remains a semantic projection, not Relay's policy language. Relay executes its small typed access contract, never arbitrary RDF, DPV rules, ODRL, or remote vocabulary content. DPV 2.3 is a W3C Community Group report rather than a W3C Recommendation, so the profile and vocabulary digest must be pinned and upgraded deliberately. -### Schema-only identification and reviewed representation governance +### Schema-only identification and reviewed access-profile governance Relay keeps one classification model. `semanticTerm` describes meaning; `privacy`, `institutional`, and `handling` describe the Registry Authority's @@ -246,7 +246,7 @@ directory at these fixed paths: - `generated/reports/identification-report.json` - `generated/reports/classification-inventory.json` -- `generated/reports/representation-report.json` +- `generated/reports/operation-explanation.json` - `generated/reports/contextual-review-findings.json` - `generated/governance/classification-review-starter.yaml` @@ -269,11 +269,14 @@ Every property has its own output classification. Every processed source column has its own reviewed source-column classification. The compiler derives processing handling from every Registry Core, output, transform input, selector, filter, order, and row-binding column, and disclosure handling from -properties serializable by the representation. Source processing controls, +properties serializable by the access profile. Source processing controls, authentication, audit, and cache use the processing floor even when a reviewed output has lower disclosure handling. Anonymous publication cannot transform a -non-public source: a public representation must read a reviewed pre-derived -public SQLite view column. +non-public source: a public access profile must read a reviewed pre-derived +public SQLite view column. `relayctl check --explain` shows this compiled +operation, processing, disclosure, transform, query, access-profile, cache, +and wire-format plan without source values or allowed authorization-claim +values. `relayctl generate` writes the same canonical explanation. Only two finite deterministic transforms are in this profile. `partial-string` accepts a reviewed string and emits Relay's fixed `***` marker plus a bounded @@ -289,13 +292,18 @@ engine are not part of this release. ### Registry operations and safe requester minimization -A resource compiles only the operations its publisher declares: collection listing, identifier read, and named exact lookup. A resource may expose any appropriate subset. An exact-lookup-only resource compiles no enumeration or identifier-read operation. +A resource compiles only the operations its publisher declares: collection +listing, identifier read, named exact lookup, and named Point-bbox search. A +resource may expose any appropriate subset. A lookup-only resource compiles no +enumeration or identifier-read operation. A list and a named search are +separate operations with separate access profiles, so either right may exist +without the other. Collection queries use publisher-defined, typed, camelCase filter parameters directly in the query string, for example `status=ACTIVE`. Version one supports exact equality. Any non-empty subset of declared filters is valid; the contract separately declares whether an unfiltered request is allowed. `pageSize`, -`cursor`, `fields`, and `representation` are reserved names. Filters in query strings are +`cursor`, `fields`, `accessProfile`, and `formatProfile` are reserved names. Filters in query strings are limited to non-personal selectors. Relay binds their values as SQL parameters. Transforms are response-only: a transformed property cannot be a filter or fixed-order key because doing so would compare or order its undisclosed raw @@ -306,7 +314,7 @@ arbitrary sorting, or SQL. Named exact lookups define their complete required inputs, row boundary, result shape, and maximum of one result. Sensitive selectors belong in a bounded request body rather than a URL. Lookup outcomes are deliberately non-enumerating and are subject to tighter limits and audit. -The selected representation's disclosure profile supplies the maximum property +The selected access profile's disclosure profile supplies the maximum property set. A caller may request a non-empty subset of those published properties, or receive the complete selected profile when no subset is requested. This is a one-way minimization control: @@ -321,9 +329,9 @@ This is a one-way minimization control: a Version one correctness contract. This is not dynamic attribute authorization. An operation has a finite ordered -map of reviewed representations, exactly one `defaultRepresentation`, and one -access rule plus one disclosure profile per representation. An absent -`representation` selects that sole declared default. A supplied representation +map of reviewed access profiles, exactly one `defaultAccessProfile`, and one +access rule plus one disclosure profile per access profile. An absent +`accessProfile` selects that sole declared default. A supplied access profile is authorized exactly as requested: denial, an invalid bearer, or an unknown identifier never falls back to another profile. A syntactically valid unknown name and a scope-hidden name receive the same `resource.not_found` response, so @@ -344,9 +352,10 @@ GET /openapi.json GET /v2 GET /v2/resources?pageSize=...&cursor=... GET /v2/resources/{resource} -GET /v2/resources/{resource}/records?pageSize=...&cursor=...&status=...&representation=...&fields=... -GET /v2/resources/{resource}/records/{recordIdentifier}?representation=...&fields=... -POST /v2/resources/{resource}/lookups/{lookup}?representation=...&fields=... +GET /v2/resources/{resource}/records?pageSize=...&cursor=...&status=...&accessProfile=...&fields=...&formatProfile=... +GET /v2/resources/{resource}/records/{recordIdentifier}?accessProfile=...&fields=...&formatProfile=... +POST /v2/resources/{resource}/lookups/{lookup}?accessProfile=...&fields=...&formatProfile=... +GET /v2/resources/{resource}/searches/{search}?bbox=...&pageSize=...&cursor=...&accessProfile=...&fields=...&formatProfile=... GET /v2/artifacts/{artifactIdentifier} ``` @@ -368,11 +377,12 @@ Lists use `pageSize`, `cursor`, and the envelope `{items, pageInfo: {nextCursor}, meta}`. `nextCursor` is nullable. Ordering is contract-defined with the Record identifier as a unique tie-breaker. The client-opaque authenticated-encrypted cursor binds the contract and source -revisions, operation, selected representation and disclosure profile, filters, +revisions, operation, selected access profile and disclosure profile, filters, order, selected fields, authorization context, and expiry. Every page is reauthorized. Encryption prevents its filter and keyset-order state from bypassing field minimization. Callers treat it as an uninterpreted continuation -token and cannot choose an order or replay a cursor across representations. +token and cannot choose an order or replay a cursor across access profiles, +named searches, bbox predicates, wire formats, or format profiles. Single-record reads and resolved lookups use `{data, meta}`. `data` contains the Registry Core context and `domainData`. `fields` is a documented Relay @@ -380,17 +390,17 @@ extension: a non-empty, duplicate-free comma-separated list of published propert keys. A property key is the contract's URL-safe camelCase name, not a source column or semantic IRI. Exactly one `fields` parameter is accepted; empty members, whitespace, repeats, and duplicate keys are invalid. It only narrows -the selected representation's `domainData`; Registry Core context cannot be +the selected access profile's `domainData`; Registry Core context cannot be removed, and response ordering remains contract-defined rather than -request-defined. A field outside the selected representation is rejected +request-defined. A field outside the selected access profile is rejected before source access. Ordinary JSON is the default. `application/ld+json` adds the generated context and a derived global `@id` while preserving all Registry Core identifiers and -the same selected domain values. Responses vary on `Accept`; unsupported -representations receive `406`. Where caching is allowed, the strong ETag hashes -the exact representation bytes, including the selected representation and field -subset, and supports `If-None-Match` with `304`. Only a public representation +the same selected domain values. Responses vary on `Accept`; unsupported wire +formats receive `406 format.unsupported`. Where caching is allowed, the strong ETag hashes +the exact response bytes, including the selected access profile, wire format, +format profile, and field subset, and supports `If-None-Match` with `304`. Only a public access profile with a public processing floor over a snapshot may be cacheable. Every cacheable public response includes `Vary: Accept, Authorization` so an anonymous `200` cannot satisfy a request carrying an invalid bearer. Non-public and unversioned-live responses are @@ -406,27 +416,31 @@ source-required validation, requester minimization, access, audit, and cache posture as every other published field. Requesting GeoJSON never grants a different right or a wider property set. -An opted list may declare bounded exact point `bbox` search. All four CRS84 +An opted resource may declare a named bounded exact Point-bbox search. The +operation owns its query shape, path, pagination, order, and finite access +profiles. It requires exactly one `bbox`; a list never accepts `bbox`, and a +list scope never grants the search. All four CRS84 coordinates must be finite and in range, use increasing bounds, avoid antimeridian crossing, and remain within publisher-declared longitude and latitude spans. Relay executes inclusive point containment against the reviewed coordinate columns. Because `bbox` is a collection-query selector, its primary geometry must be classified `privacy: non-personal`; personal locations require -a different bounded consultation design. A cursor binds bbox, the selected -governed representation, response format, and GeoJSON profile as well as the -ordinary list context. +a different bounded consultation design. A cursor binds the named search, +bbox, selected access profile, wire format, and format profile as well as the +ordinary collection context. `application/geo+json` is available only when the resource declares a primary -geometry and the exact selected governed representation discloses it. It +geometry and the exact selected access profile discloses it. It returns an RFC 7946 `Feature` or `FeatureCollection`. Feature `properties` carry Registry Core and the selected non-spatial domain fields, while `geometry` carries the selected primary geometry; together they preserve the -same governed disclosure as ordinary JSON. `profile=rfc7946` is the default; -`profile=jsonfg` adds JSON-FG profile metadata while retaining valid GeoJSON +same governed disclosure as ordinary JSON. `formatProfile=rfc7946` is the default; +`formatProfile=jsonfg` adds JSON-FG profile metadata while retaining valid GeoJSON core. A caller may select fewer fields, including the geometry, in which case -the GeoJSON feature geometry is `null`. `representation=` still selects the -finite access and disclosure contract; `Accept` and `profile` select only its -wire format. This profile intentionally excludes +the GeoJSON feature geometry is `null`. `accessProfile=` still selects the +finite access and disclosure contract; `Accept` selects the wire format and +`formatProfile=` optionally selects the serialization profile. This spatial +profile intentionally excludes generic geometry ingestion, OGC API Features routes, CQL2, EDR, tiles, reprojection, and spatial joins. @@ -437,7 +451,7 @@ metadata. Protected resource metadata and referenced artifacts use the same compiled operation gate as the Record that links them. Relay never generates caller-specific OpenAPI at request time. -Every `schemaReference` is a permitted-representation schema: Registry Core is +Every `schemaReference` is a permitted access-profile schema: Registry Core is required, while a selectable `domainData` property is validated when present. A separate operator validation schema and SHACL shape describe the complete source Record and preserve source requiredness. `semanticModelReference` points @@ -496,16 +510,31 @@ disclosure profile as described above. The initial access model combines: - a strictly verified OAuth 2.0 JWT access token when the operation is protected; -- one explicit access rule for each finite representation, with an exact scope when protected; +- one explicit access rule for each finite access profile, with an exact scope when protected; - optional trusted purpose; - optional authority-to-row binding from the resolved principal or a verified claim; - the selected disclosure profile. Purpose comes from, or is constrained by, verified authority. A caller header never creates authority. Principal binding is a compiler-declared equality boundary injected by Relay and cannot be replaced by caller filters. -The resource posture and contract define the maximum compiled operation set. Token scopes can only narrow it. Separate scopes for list, read, named lookup, and their finite representations allow an issuer to give a client exact-lookup access without collection or identifier-read access. A valid principal without the selected scope receives the same concealed `resource.not_found` outcome as an unknown operation. Conversely, no token can enable an operation or representation the resource did not compile. Relay does not maintain a client registry; the trusted issuer registers clients and assigns scopes. - -Each request produces a typed access decision followed by a typed disclosure plan. The plan contains the authorized operation and representation, row constraints, selected disclosure profile, and any requester-selected property subset. This architectural seam keeps authentication, authorization, row constraints, query construction, and serialization separate. Version one uses static reviewed disclosure plans and does not require a general PDP, CEL, dynamic masking, per-client field permissions, or tag-based ABAC. +The resource posture and contract define the maximum compiled operation set. +Token scopes can only narrow it. Separate scopes for list, read, named lookup, +named search, and their finite access profiles allow an issuer to give a client +search or exact-lookup access without collection or identifier-read access. A +valid principal without the selected scope receives the same concealed +`resource.not_found` outcome as an unknown operation. Conversely, no token can +enable an operation or access profile the resource did not compile. Relay does +not maintain a client registry; the trusted issuer registers clients and +assigns scopes. + +Each request produces a typed access decision followed by a typed disclosure +plan. The plan contains the authorized operation and access profile, row +constraints, selected disclosure profile, and any requester-selected property +subset. Wire-format negotiation happens after that governed decision and +cannot widen it. This architectural seam keeps authentication, authorization, +row constraints, query construction, and serialization separate. Version one +uses static reviewed disclosure plans and does not require a general PDP, CEL, +dynamic masking, per-client field permissions, or tag-based ABAC. ### Token issuers and optional Registry Mint @@ -580,7 +609,7 @@ Every data request, including anonymous public access, durably records either a refusal before returning or a pre-source attempt followed by a terminal release, unresolved, or source-failed outcome. Audit is a source-access and response-release gate. An event identifies the Registry, resource, operation, access-rule -revision, purpose when present, applied row-boundary kind, representation, +revision, purpose when present, applied row-boundary kind, access profile, disclosure profile, selected property identifiers or their digest, transform identifiers, effective handling levels, contract revision, and snapshot or live-source revision. Anonymous public @@ -746,8 +775,8 @@ The first coherent Relay V2 release should contain: 4. generated Consultation capability discovery and a concise Digital Registries alignment note; 5. generated local semantics, JSON-LD, JSON Schema, SHACL, full packaged OpenAPI, and safe public OpenAPI; 6. property classification with provenance and the `public`, `internal`, `confidential`, and `restricted` handling levels; -7. derived public, protected, or absent enumeration with independently compiled list, read, and named-lookup operations; -8. `pageSize` and client-opaque authenticated-encrypted cursor lists, direct predefined equality filters, and safe caller selection of fewer properties than the selected representation; +7. derived public, protected, or absent enumeration with independently compiled list, read, named-lookup, and named Point-bbox search operations; +8. `pageSize` and client-opaque authenticated-encrypted collection cursors, direct predefined equality filters, and safe caller selection of fewer properties than the selected access profile; 9. strict OAuth JWT access-token verification, operation scopes, trusted purpose, optional authority row binding, and an optional conforming Mint issuer; 10. snapshot and live read-only SQLite profiles, including useful unversioned live read and lookup deployments; 11. deterministic disclosure plans, bounded queries, stable `404` lookup outcomes, atomic activation, and Registry Stack problems; @@ -790,10 +819,11 @@ shape. The generated schema makes their constraints precise. - Relay owns a strict registry contract. A Registry Manifest projection is a later portability artifact, not the Relay execution contract. - One contract and process serves one Registry, which may contain several resources. - Registry Core context is mandatory and requester field selection can narrow only `domainData`. -- Lists use `pageSize`, `cursor`, `items`, and `pageInfo.nextCursor`; predefined filters are direct camelCase equality parameters, and an opted point resource may accept bounded exact `bbox`. +- Lists use `pageSize`, `cursor`, `items`, and `pageInfo.nextCursor`; predefined filters are direct camelCase equality parameters. A separately named Point-bbox search requires bounded exact `bbox` and has its own access profiles. - Named exact lookup remains a bounded POST action and maps to constrained Consultation Search, not Record Match. -- Each operation has finite reviewed representations, an explicit sole default, - and representation-owned access plus disclosure. Dynamic, caller-derived +- Each operation has finite reviewed access profiles, an explicit sole default, + and access-profile-owned access plus disclosure. Wire format and optional + format profile are independent serialization choices. Dynamic, caller-derived entitlement variants are deferred. - Handling levels are `public`, `internal`, `confidential`, and `restricted`; purpose and row binding are separate explicit constraints. - Snapshot SQLite is valuable but optional. Unversioned live sources are `no-store` and do not compile paginated lists. diff --git a/products/relay-v2/CONFIGURATION-EXAMPLES.md b/products/relay-v2/CONFIGURATION-EXAMPLES.md index 289e0856f..8f62fe58e 100644 --- a/products/relay-v2/CONFIGURATION-EXAMPLES.md +++ b/products/relay-v2/CONFIGURATION-EXAMPLES.md @@ -20,9 +20,10 @@ The intended boundaries are firmer than the syntax: - SQLite views and columns are source bindings, while resources and properties are the public model; - one contract describes one Registry; each resource is a Record type within it; - every Record has mandatory Registry Core bindings in addition to selectable domain properties; -- `sourceRequired` governs complete source-Record validation, while the public representation schema permits any compiled selectable `domainData` subset; +- `sourceRequired` governs complete source-Record validation, while the public access-profile schema permits any compiled selectable `domainData` subset; - external semantic alignment is optional and file-based, pinned, and reviewed; -- every operation declares one default and a finite ordered set of representations; an operation with any public representation uses a public default; access and disclosure belong to the representation, while the requester may only select fewer properties within the chosen representation; +- every operation declares one default and a finite ordered set of access profiles; an operation with any public access profile uses a public default; access and disclosure belong to the access profile, while the requester may only select fewer properties within it; +- wire format and optional `formatProfile` are serialization choices after the access-profile decision and never grant data access; - token issuers assign authority, but they cannot enable an operation Relay did not compile; - family capabilities are derived from compiled operations, never duplicated in configuration; - none of these examples enables response signing. @@ -59,7 +60,7 @@ Every successful item has the same non-selectable core shape. For example: `fields=legalName,registrationStatus` narrowed only `domainData`. List responses place such items in `items`; single reads and resolved lookups place one in `data`. The semantic-model reference resolves to the generated local vocabulary; -the representation `meta` links the JSON-LD context separately. +the response `meta` links the JSON-LD context separately. Each example shows the governed and runtime documents together for readability. A real project would keep them as separately validated files and package them with synthetic fixtures and generated artifacts. @@ -215,8 +216,8 @@ resources: selectors: caseReference: {sourceColumn: case_reference, type: string, minimumBytes: 8, maximumBytes: 96} personReference: {sourceColumn: person_reference, type: string, minimumBytes: 8, maximumBytes: 96} - defaultRepresentation: limited - representations: + defaultAccessProfile: limited + accessProfiles: limited: access: scope: registry:social-assistance:limited @@ -417,8 +418,8 @@ resources: operations: list: - defaultRepresentation: public-register - representations: + defaultAccessProfile: public-register + accessProfiles: public-register: {access: public, disclosureProfile: public-register} registrar: {access: {scope: registry:business:list-registrar}, disclosureProfile: registrar-register} filters: @@ -428,8 +429,8 @@ resources: orderBy: [registrationNumber] pagination: {defaultPageSize: 50, maximumPageSize: 200} read: - defaultRepresentation: public-register - representations: + defaultAccessProfile: public-register + accessProfiles: public-register: {access: public, disclosureProfile: public-register} registrar: {access: {scope: registry:business:read-registrar}, disclosureProfile: registrar-register} @@ -479,10 +480,12 @@ not a generic map service or a spatial database API. The reviewed source view owns the two numeric carrier columns; Relay validates and reconstructs one CRS84 GeoJSON Point only after its complete Record is safe to disclose. The geometry has a normal classification and enters the maximum disclosure profile, -so `fields` can remove it but cannot add it. `bbox` is a fixed publisher-owned -query shape, not an expression language. A bbox-enabled primary geometry must -be classified `privacy: non-personal`, even for a protected list. Its maximum -spans keep an anonymous public search local and bounded. +so `fields` can remove it but cannot add it. `bbox` is the required input of a +named publisher-owned search, not an expression language and not a list option. +A searched primary geometry must be classified `privacy: non-personal`. Its +maximum spans keep an anonymous public search local and bounded, while a +separately scoped list demonstrates that list and search rights do not imply +one another. ```yaml - id: registered-premises @@ -521,31 +524,45 @@ spans keep an anonymous public search local and bounded. label: Business registration number description: Registered business associated with the premises disclosureProfiles: - public-premises: {properties: [premisesIdentifier, businessRegistrationNumber, location]} + public-premises: {properties: [premisesIdentifier, location]} + registrar-premises: {properties: [premisesIdentifier, businessRegistrationNumber, location]} operations: list: - defaultRepresentation: public-premises - representations: - public-premises: {access: public, disclosureProfile: public-premises} - allowUnfiltered: false - spatialQuery: - bbox: {maximumLongitudeSpanDegrees: 2, maximumLatitudeSpanDegrees: 2} + defaultAccessProfile: registrar-premises + accessProfiles: + registrar-premises: {access: {scope: registry:business:premises-list}, disclosureProfile: registrar-premises} + allowUnfiltered: true orderBy: [premisesIdentifier] pagination: {defaultPageSize: 50, maximumPageSize: 200} read: - defaultRepresentation: public-premises - representations: + defaultAccessProfile: public-premises + accessProfiles: public-premises: {access: public, disclosureProfile: public-premises} + registrar-premises: {access: {scope: registry:business:premises-read-registrar}, disclosureProfile: registrar-premises} + searches: + - id: within-bbox + query: + kind: point-bbox + maximumLongitudeSpanDegrees: 2 + maximumLatitudeSpanDegrees: 2 + defaultAccessProfile: public-premises + accessProfiles: + public-premises: {access: public, disclosureProfile: public-premises} + registrar-premises: {access: {scope: registry:business:premises-search-registrar}, disclosureProfile: registrar-premises} + orderBy: [premisesIdentifier] + pagination: {defaultPageSize: 50, maximumPageSize: 200} ``` -The `public-premises` governed representation is the access and maximum +The `public-premises` access profile is the access and maximum disclosure decision. JSON and JSON-LD are always available for it; because its disclosure profile includes `location`, `Accept: application/geo+json` is also available and returns RFC 7946 by default. A client may ask for -`profile=jsonfg` to receive JSON-FG conformance metadata. In both forms, +`formatProfile=jsonfg` to receive JSON-FG conformance metadata. In both forms, Feature `properties` plus the separately selected `geometry` carry the same governed disclosure as ordinary JSON. A query such as -`bbox=100,13,101,14` includes only points within that closed inclusive extent; +`GET /v2/resources/registered-premises/searches/within-bbox?bbox=100,13,101,14` +includes only points within that closed inclusive extent. The protected list +and protected search profile have independent scopes; antimeridian-crossing boxes, arbitrary CRS requests, CQL2, tiles, EDR, spatial joins, and dynamic SpatiaLite functions are not part of this profile. @@ -700,8 +717,8 @@ resources: operations: read: - defaultRepresentation: registrar - representations: + defaultAccessProfile: registrar + accessProfiles: registrar: access: scope: registry:civil-events:read @@ -715,8 +732,8 @@ resources: selectors: registrationNumber: {sourceColumn: registration_number, type: string, minimumBytes: 12, maximumBytes: 96} eventType: {sourceColumn: event_type, type: controlled-code, codelist: codelists/civil-event-types.yaml} - defaultRepresentation: registrar-verification - representations: + defaultAccessProfile: registrar-verification + accessProfiles: registrar-verification: access: scope: registry:civil-events:lookup @@ -776,7 +793,7 @@ quotas: {requestsPerMinute: 120, burst: 20} ## Complete accepted key-path inventory -The following blocks come from successful typed `relayctl check --production` +The following blocks come from successful typed `relayctl check --production --explain` reports for all three coequal acceptance projects. They describe the complete strict configuration surface exercised by those projects. Run `products/relay-v2/scripts/check-configs.sh --write` after an intentional model @@ -844,8 +861,24 @@ resources[].disclosureProfiles.*.properties[] resources[].id resources[].operations resources[].operations.list +resources[].operations.list.accessProfiles +resources[].operations.list.accessProfiles.public-register +resources[].operations.list.accessProfiles.public-register.access +resources[].operations.list.accessProfiles.public-register.disclosureProfile +resources[].operations.list.accessProfiles.registrar +resources[].operations.list.accessProfiles.registrar-premises +resources[].operations.list.accessProfiles.registrar-premises.access +resources[].operations.list.accessProfiles.registrar-premises.access.authorityRowBinding +resources[].operations.list.accessProfiles.registrar-premises.access.purpose +resources[].operations.list.accessProfiles.registrar-premises.access.scope +resources[].operations.list.accessProfiles.registrar-premises.disclosureProfile +resources[].operations.list.accessProfiles.registrar.access +resources[].operations.list.accessProfiles.registrar.access.authorityRowBinding +resources[].operations.list.accessProfiles.registrar.access.purpose +resources[].operations.list.accessProfiles.registrar.access.scope +resources[].operations.list.accessProfiles.registrar.disclosureProfile resources[].operations.list.allowUnfiltered -resources[].operations.list.defaultRepresentation +resources[].operations.list.defaultAccessProfile resources[].operations.list.filters resources[].operations.list.filters[] resources[].operations.list.filters[].name @@ -856,72 +889,55 @@ resources[].operations.list.orderBy[] resources[].operations.list.pagination resources[].operations.list.pagination.defaultPageSize resources[].operations.list.pagination.maximumPageSize -resources[].operations.list.representations -resources[].operations.list.representations.public-premises -resources[].operations.list.representations.public-premises.access -resources[].operations.list.representations.public-premises.disclosureProfile -resources[].operations.list.representations.public-register -resources[].operations.list.representations.public-register.access -resources[].operations.list.representations.public-register.disclosureProfile -resources[].operations.list.representations.registrar -resources[].operations.list.representations.registrar.access -resources[].operations.list.representations.registrar.access.authorityRowBinding -resources[].operations.list.representations.registrar.access.purpose -resources[].operations.list.representations.registrar.access.scope -resources[].operations.list.representations.registrar.disclosureProfile -resources[].operations.list.spatialQuery -resources[].operations.list.spatialQuery.bbox -resources[].operations.list.spatialQuery.bbox.maximumLatitudeSpanDegrees -resources[].operations.list.spatialQuery.bbox.maximumLongitudeSpanDegrees resources[].operations.lookups resources[].operations.lookups[] -resources[].operations.lookups[].defaultRepresentation +resources[].operations.lookups[].accessProfiles +resources[].operations.lookups[].accessProfiles.caseworker +resources[].operations.lookups[].accessProfiles.caseworker.access +resources[].operations.lookups[].accessProfiles.caseworker.access.authorityRowBinding +resources[].operations.lookups[].accessProfiles.caseworker.access.authorityRowBinding.claim +resources[].operations.lookups[].accessProfiles.caseworker.access.authorityRowBinding.sourceColumn +resources[].operations.lookups[].accessProfiles.caseworker.access.purpose +resources[].operations.lookups[].accessProfiles.caseworker.access.purpose.allowed +resources[].operations.lookups[].accessProfiles.caseworker.access.purpose.allowed[] +resources[].operations.lookups[].accessProfiles.caseworker.access.purpose.claim +resources[].operations.lookups[].accessProfiles.caseworker.access.scope +resources[].operations.lookups[].accessProfiles.caseworker.disclosureProfile +resources[].operations.lookups[].accessProfiles.limited +resources[].operations.lookups[].accessProfiles.limited.access +resources[].operations.lookups[].accessProfiles.limited.access.authorityRowBinding +resources[].operations.lookups[].accessProfiles.limited.access.authorityRowBinding.claim +resources[].operations.lookups[].accessProfiles.limited.access.authorityRowBinding.sourceColumn +resources[].operations.lookups[].accessProfiles.limited.access.purpose +resources[].operations.lookups[].accessProfiles.limited.access.purpose.allowed +resources[].operations.lookups[].accessProfiles.limited.access.purpose.allowed[] +resources[].operations.lookups[].accessProfiles.limited.access.purpose.claim +resources[].operations.lookups[].accessProfiles.limited.access.scope +resources[].operations.lookups[].accessProfiles.limited.disclosureProfile +resources[].operations.lookups[].accessProfiles.registrar-verification +resources[].operations.lookups[].accessProfiles.registrar-verification.access +resources[].operations.lookups[].accessProfiles.registrar-verification.access.authorityRowBinding +resources[].operations.lookups[].accessProfiles.registrar-verification.access.authorityRowBinding.claim +resources[].operations.lookups[].accessProfiles.registrar-verification.access.authorityRowBinding.sourceColumn +resources[].operations.lookups[].accessProfiles.registrar-verification.access.purpose +resources[].operations.lookups[].accessProfiles.registrar-verification.access.purpose.allowed +resources[].operations.lookups[].accessProfiles.registrar-verification.access.purpose.allowed[] +resources[].operations.lookups[].accessProfiles.registrar-verification.access.purpose.claim +resources[].operations.lookups[].accessProfiles.registrar-verification.access.scope +resources[].operations.lookups[].accessProfiles.registrar-verification.disclosureProfile +resources[].operations.lookups[].accessProfiles.supervisory +resources[].operations.lookups[].accessProfiles.supervisory.access +resources[].operations.lookups[].accessProfiles.supervisory.access.authorityRowBinding +resources[].operations.lookups[].accessProfiles.supervisory.access.authorityRowBinding.claim +resources[].operations.lookups[].accessProfiles.supervisory.access.authorityRowBinding.sourceColumn +resources[].operations.lookups[].accessProfiles.supervisory.access.purpose +resources[].operations.lookups[].accessProfiles.supervisory.access.purpose.allowed +resources[].operations.lookups[].accessProfiles.supervisory.access.purpose.allowed[] +resources[].operations.lookups[].accessProfiles.supervisory.access.purpose.claim +resources[].operations.lookups[].accessProfiles.supervisory.access.scope +resources[].operations.lookups[].accessProfiles.supervisory.disclosureProfile +resources[].operations.lookups[].defaultAccessProfile resources[].operations.lookups[].id -resources[].operations.lookups[].representations -resources[].operations.lookups[].representations.caseworker -resources[].operations.lookups[].representations.caseworker.access -resources[].operations.lookups[].representations.caseworker.access.authorityRowBinding -resources[].operations.lookups[].representations.caseworker.access.authorityRowBinding.claim -resources[].operations.lookups[].representations.caseworker.access.authorityRowBinding.sourceColumn -resources[].operations.lookups[].representations.caseworker.access.purpose -resources[].operations.lookups[].representations.caseworker.access.purpose.allowed -resources[].operations.lookups[].representations.caseworker.access.purpose.allowed[] -resources[].operations.lookups[].representations.caseworker.access.purpose.claim -resources[].operations.lookups[].representations.caseworker.access.scope -resources[].operations.lookups[].representations.caseworker.disclosureProfile -resources[].operations.lookups[].representations.limited -resources[].operations.lookups[].representations.limited.access -resources[].operations.lookups[].representations.limited.access.authorityRowBinding -resources[].operations.lookups[].representations.limited.access.authorityRowBinding.claim -resources[].operations.lookups[].representations.limited.access.authorityRowBinding.sourceColumn -resources[].operations.lookups[].representations.limited.access.purpose -resources[].operations.lookups[].representations.limited.access.purpose.allowed -resources[].operations.lookups[].representations.limited.access.purpose.allowed[] -resources[].operations.lookups[].representations.limited.access.purpose.claim -resources[].operations.lookups[].representations.limited.access.scope -resources[].operations.lookups[].representations.limited.disclosureProfile -resources[].operations.lookups[].representations.registrar-verification -resources[].operations.lookups[].representations.registrar-verification.access -resources[].operations.lookups[].representations.registrar-verification.access.authorityRowBinding -resources[].operations.lookups[].representations.registrar-verification.access.authorityRowBinding.claim -resources[].operations.lookups[].representations.registrar-verification.access.authorityRowBinding.sourceColumn -resources[].operations.lookups[].representations.registrar-verification.access.purpose -resources[].operations.lookups[].representations.registrar-verification.access.purpose.allowed -resources[].operations.lookups[].representations.registrar-verification.access.purpose.allowed[] -resources[].operations.lookups[].representations.registrar-verification.access.purpose.claim -resources[].operations.lookups[].representations.registrar-verification.access.scope -resources[].operations.lookups[].representations.registrar-verification.disclosureProfile -resources[].operations.lookups[].representations.supervisory -resources[].operations.lookups[].representations.supervisory.access -resources[].operations.lookups[].representations.supervisory.access.authorityRowBinding -resources[].operations.lookups[].representations.supervisory.access.authorityRowBinding.claim -resources[].operations.lookups[].representations.supervisory.access.authorityRowBinding.sourceColumn -resources[].operations.lookups[].representations.supervisory.access.purpose -resources[].operations.lookups[].representations.supervisory.access.purpose.allowed -resources[].operations.lookups[].representations.supervisory.access.purpose.allowed[] -resources[].operations.lookups[].representations.supervisory.access.purpose.claim -resources[].operations.lookups[].representations.supervisory.access.scope -resources[].operations.lookups[].representations.supervisory.disclosureProfile resources[].operations.lookups[].requestBody resources[].operations.lookups[].requestBody.maximumBytes resources[].operations.lookups[].requestBody.selectors @@ -932,25 +948,54 @@ resources[].operations.lookups[].requestBody.selectors.*.minimumBytes resources[].operations.lookups[].requestBody.selectors.*.sourceColumn resources[].operations.lookups[].requestBody.selectors.*.type resources[].operations.read -resources[].operations.read.defaultRepresentation -resources[].operations.read.representations -resources[].operations.read.representations.public-premises -resources[].operations.read.representations.public-premises.access -resources[].operations.read.representations.public-premises.disclosureProfile -resources[].operations.read.representations.public-register -resources[].operations.read.representations.public-register.access -resources[].operations.read.representations.public-register.disclosureProfile -resources[].operations.read.representations.registrar -resources[].operations.read.representations.registrar.access -resources[].operations.read.representations.registrar.access.authorityRowBinding -resources[].operations.read.representations.registrar.access.authorityRowBinding.claim -resources[].operations.read.representations.registrar.access.authorityRowBinding.sourceColumn -resources[].operations.read.representations.registrar.access.purpose -resources[].operations.read.representations.registrar.access.purpose.allowed -resources[].operations.read.representations.registrar.access.purpose.allowed[] -resources[].operations.read.representations.registrar.access.purpose.claim -resources[].operations.read.representations.registrar.access.scope -resources[].operations.read.representations.registrar.disclosureProfile +resources[].operations.read.accessProfiles +resources[].operations.read.accessProfiles.public-premises +resources[].operations.read.accessProfiles.public-premises.access +resources[].operations.read.accessProfiles.public-premises.disclosureProfile +resources[].operations.read.accessProfiles.public-register +resources[].operations.read.accessProfiles.public-register.access +resources[].operations.read.accessProfiles.public-register.disclosureProfile +resources[].operations.read.accessProfiles.registrar +resources[].operations.read.accessProfiles.registrar-premises +resources[].operations.read.accessProfiles.registrar-premises.access +resources[].operations.read.accessProfiles.registrar-premises.access.authorityRowBinding +resources[].operations.read.accessProfiles.registrar-premises.access.purpose +resources[].operations.read.accessProfiles.registrar-premises.access.scope +resources[].operations.read.accessProfiles.registrar-premises.disclosureProfile +resources[].operations.read.accessProfiles.registrar.access +resources[].operations.read.accessProfiles.registrar.access.authorityRowBinding +resources[].operations.read.accessProfiles.registrar.access.authorityRowBinding.claim +resources[].operations.read.accessProfiles.registrar.access.authorityRowBinding.sourceColumn +resources[].operations.read.accessProfiles.registrar.access.purpose +resources[].operations.read.accessProfiles.registrar.access.purpose.allowed +resources[].operations.read.accessProfiles.registrar.access.purpose.allowed[] +resources[].operations.read.accessProfiles.registrar.access.purpose.claim +resources[].operations.read.accessProfiles.registrar.access.scope +resources[].operations.read.accessProfiles.registrar.disclosureProfile +resources[].operations.read.defaultAccessProfile +resources[].operations.searches +resources[].operations.searches[] +resources[].operations.searches[].accessProfiles +resources[].operations.searches[].accessProfiles.public-premises +resources[].operations.searches[].accessProfiles.public-premises.access +resources[].operations.searches[].accessProfiles.public-premises.disclosureProfile +resources[].operations.searches[].accessProfiles.registrar-premises +resources[].operations.searches[].accessProfiles.registrar-premises.access +resources[].operations.searches[].accessProfiles.registrar-premises.access.authorityRowBinding +resources[].operations.searches[].accessProfiles.registrar-premises.access.purpose +resources[].operations.searches[].accessProfiles.registrar-premises.access.scope +resources[].operations.searches[].accessProfiles.registrar-premises.disclosureProfile +resources[].operations.searches[].defaultAccessProfile +resources[].operations.searches[].id +resources[].operations.searches[].orderBy +resources[].operations.searches[].orderBy[] +resources[].operations.searches[].pagination +resources[].operations.searches[].pagination.defaultPageSize +resources[].operations.searches[].pagination.maximumPageSize +resources[].operations.searches[].query +resources[].operations.searches[].query.kind +resources[].operations.searches[].query.maximumLatitudeSpanDegrees +resources[].operations.searches[].query.maximumLongitudeSpanDegrees resources[].primaryGeometry resources[].primaryGeometry.classification resources[].primaryGeometry.classification.handling @@ -1089,8 +1134,9 @@ registry contract -> source reference and reviewed view -> resource and published properties -> compiled operation query shape - -> finite defaulted representations with access and disclosure + -> finite defaulted access profiles with access and disclosure -> optional requester property subset + -> independent wire format and optional format profile -> access constraints -> semantics, classification, and processing description -> deterministic query, response, revision, and audit evidence @@ -1100,10 +1146,11 @@ The examples also freeze these boundaries: - Registry Manifest projection is later portability tooling, not a runtime input; - Registry Core fields are native and cannot be removed; -- `fields` is one comma-separated property syntax across list, read, and lookup; +- `fields` is one comma-separated property syntax across list, read, lookup, and named search; - a live source remains useful for read and lookup but is unversioned, `no-store`, and has no paginated list; - handling uses `public`, `internal`, `confidential`, and `restricted`, while purpose and row binding remain explicit constraints; -- one explicit default and a finite ordered representation set is compiled per operation; requester `fields` only narrows the selected profile and caller-derived variants are deferred; +- one explicit default and a finite ordered access-profile set is compiled per operation; requester `fields` only narrows the selected profile and caller-derived variants are deferred; +- a named Point-bbox search owns its required query shape and access profiles; list access cannot synthesize search access or accept `bbox`; - identification is schema-only and value-free; generated, imported, and manual classification review all bind the complete classification inventory before production compilation; - only `partial-string` with Relay's fixed `***` marker and `date-precision` to `year` or `year-month` are transform forms; every transform produces a distinct reviewed property; - Mint and external issuers use one strict Relay JWT access-token profile; diff --git a/products/relay-v2/DEFINITION-OF-DONE.md b/products/relay-v2/DEFINITION-OF-DONE.md index c057505af..efa3a9851 100644 --- a/products/relay-v2/DEFINITION-OF-DONE.md +++ b/products/relay-v2/DEFINITION-OF-DONE.md @@ -22,9 +22,9 @@ No required behavior may remain as a stub, TODO, undocumented manual step, disab | Registry | Required shape | What it must prove | |---|---|---| -| Social assistance enrolment | Live SQLite, exact lookup only, limited and caseworker representations, partial-string transform, trusted purpose, authority-to-row binding, external authorization server | A sensitive person-related registry can answer a bounded consultation without enumeration, selector disclosure, or domain-specific runtime behavior. | -| Business registration | Snapshot SQLite, public default plus protected registrar representations, predefined exact filters, pagination, public semantics | A genuinely public register can isolate a protected representation while its reviewed pre-derived public view remains discoverable and cacheable. | -| Civil event registration | Live SQLite, registrar and supervisory representations over protected identifier read and named exact lookup, date-precision transform, no list | A CRVS-shaped event register can prove exact lookup and representation scope separation without exposing a collection, coupling Relay to Mint, or moving signed assertions into Relay. | +| Social assistance enrolment | Live SQLite, exact lookup only, limited and caseworker access profiles, partial-string transform, trusted purpose, authority-to-row binding, external authorization server | A sensitive person-related registry can answer a bounded consultation without enumeration, selector disclosure, or domain-specific runtime behavior. | +| Business registration | Snapshot SQLite, public and protected access profiles, predefined exact filters, separate list and named Point-bbox search rights, pagination, public semantics | A genuinely public register can isolate protected access while its reviewed pre-derived public view remains discoverable and cacheable. | +| Civil event registration | Live SQLite, registrar and supervisory access profiles over protected identifier read and named exact lookup, date-precision transform, no list | A CRVS-shaped event register can prove exact lookup and access-profile scope separation without exposing a collection, coupling Relay to Mint, or moving signed assertions into Relay. | Each definition must pass offline fixture evaluation and the real HTTP runtime as its own one-Registry deployment. All three must use the same compiler, @@ -37,35 +37,35 @@ prove in-process resource isolation without adding a fourth deployment project. | Area | Done when | |---|---| | Product boundary | One `relay` runtime and one separate `relayctl` implement the initial product. Relay is a read-only governed registry publisher, not a SQL proxy, write API, policy engine, credential issuer, or signed-assertion service. The existing `registryctl` is unchanged and unused by Relay V2. | -| Governed contract | A concise, closed, versioned authoring contract defines resources, source views, identifiers, properties, finite representations, operations, semantics, classifications, access rules, bounds, and metadata visibility. Each operation has one `defaultRepresentation`; each ordered `representations` entry owns exactly one `access` rule and `disclosureProfile`, while query shape remains operation-owned. Unknown fields are rejected. A deployment file may bind paths, listeners, one issuer, secrets, and audit storage but cannot override governed behavior. | +| Governed contract | A concise, closed, versioned authoring contract defines resources, source views, identifiers, properties, finite access profiles, operations, semantics, classifications, access rules, bounds, and metadata visibility. Each operation has one `defaultAccessProfile`; each ordered `accessProfiles` entry owns exactly one `access` rule and `disclosureProfile`, while query shape remains operation-owned. Unknown fields are rejected. A deployment file may bind paths, listeners, one issuer, secrets, and audit storage but cannot override governed behavior. | | Registry identity | One contract and process describes exactly one Registry with a globally stable `registryIdentifier`, name, Registry Authority, optional operator, authoritative scope, base URI, and pinned alignment targets. Resources are Record types within that Registry. Authority, controller, publisher, and operator remain distinct roles even when one institution fills several. | | Registry Core | Every returned Record contains non-selectable `registryIdentifier`, `recordIdentifier`, `revisionIdentifier`, `lifecycleState`, `schemaReference`, `semanticModelReference`, `authorityIdentifier`, `recordedAt`, and selected `domainData`. Record identifier, revision, lifecycle, and recorded time are source-bound; recorded time is never Relay observation time. The Registry and Record identifier pair is stable, and the contract names the institution's identifier-lifecycle policy without claiming Relay can prove non-reassignment from one current database. | -| Family capabilities | Each compiled operation carries a derived family and pattern: read is `consultation.retrieve`, list is `consultation.list`, and named exact lookup is constrained `consultation.search`. Capability discovery is generated from operations rather than separately authored. Relay makes no claim for Record Match or the Provisioning, Evidence, Write, Notification, Aggregate Data, Access Transparency, or Identity Federation families. | +| Family capabilities | Each compiled operation carries a derived family and pattern: read is `consultation.retrieve`, list is `consultation.list`, and named exact lookup plus named Point-bbox search are constrained `consultation.search`. Capability discovery is generated from operations rather than separately authored. Relay makes no claim for Record Match or the Provisioning, Evidence, Write, Notification, Aggregate Data, Access Transparency, or Identity Federation families. | | Compilation and activation | The shared compiler validates the complete contract before packaging, produces one deterministic compiled Registry and contract revision, and seals them with every artifact digest. Before listening, Relay verifies the closed package, recompiles the captured contract, observed schemas, and governed files to prove that the packaged runtime plan is identical, and deterministically rederives every generated artifact solely to exact-compare it with the packaged artifact set. Activation uses the verified packaged bytes. Incomplete semantics, unclassified published properties, invalid source bindings, schema drift, conflicting operations, unsafe access rules, or package inconsistency prevent packaging or readiness. There is no partial activation, runtime merge, silent fallback, or hot reload. | | Domain neutrality | Production Rust, public configuration schemas, routes, CLI options, and generated generic contracts contain no social-registry, business-registry, CRVS, birth, death, household, benefit, company, or acceptance-fixture-specific type, branch, feature, or operation. Such terms appear only in examples, fixtures, and explanatory documentation. | | SQLite source boundary | Snapshot and live read-only profiles use one hardened SQLite boundary with OS and engine read-only enforcement, defensive authorizer rules, bound values, consistent per-request transactions, schema fingerprints, and step, time, row, cell, response, queue, and concurrency limits. Writes, schema changes, control statements, attachment, extension loading, and unreviewed SQL are impossible through the public contract. | | Snapshot profile | A deployment-bound snapshot is captured outside the contract package, bound to its exact file identity and digest, refuses unsafe sidecars or path replacement, opens immutably, verifies exact bytes before and after each statement execution, and releases no rows under a stale source revision. The deployment keeps the file externally immutable, preferably on a read-only mount. Identical governed package and snapshot inputs produce identical revisions and generated artifacts. Snapshot mode is supported but not required for a deployment. | | Live profile | A live database is opened read-only while a separately trusted publisher may update it. Each response uses one consistent read transaction and verifies the expected schema fingerprint. Version one live sources compile read and exact lookup only, always return `sourceRevision: {profile: live, status: unversioned, value: null}`, use `no-store`, and emit no ETag. Publisher-owned revisions, live pagination, and live caching are deferred. | -| Closed operation model | Resources compile only declared list, identifier-read, and named exact-lookup operations. A list's operation-owned query shape determines whether enumeration is permitted; absence of list means no enumeration. Collection filters are direct publisher-defined camelCase query parameters, typed, non-personal, and exact-equality only. Transformed properties cannot be filters or fixed-order keys; queryable derived values must be reviewed pre-derived source properties. Any non-empty subset of declared filters is valid, and the contract separately permits or forbids unfiltered access. `pageSize`, `cursor`, `fields`, and `representation` are reserved. Lookups have complete bounded body inputs and exactly zero or one disclosed result. Callers cannot add SQL, source columns, joins, expressions, operators, paths, projection expressions, sort orders, or page traversal. | -| Representation selection and requester minimization | Every operation has a finite ordered `representations` map and exactly one explicit `defaultRepresentation`. If any representation is public, the default must be public so omission is truthful in public OpenAPI and anonymous clients never select a hidden protected default. The direct `representation` parameter accepts exactly one non-empty compiled identifier; absence selects the default. Relay authenticates a supplied bearer before selection and authorizes only the selected representation. Malformed, repeated, or empty selection is `400 request.representation_invalid`; a syntactically valid unknown name, an anonymous explicit request for a protected name, and a valid principal without the selected representation scope receive the same concealed `404 resource.not_found` outcome; purpose or row-binding denial after scope selection is `403 consultation.denied`. No request falls back to another representation or reaches source access after refusal. `fields` may select only a non-empty duplicate-free subset of that selected profile. Registry Core remains present. Unknown, internal, source-column, cross-profile, or malformed field selections fail before source access and cannot change predicates, bindings, transforms, validation, authorization, effective handling, audit, quota, metadata, or cache posture. | -| HTTP and pagination | Business routes are under `/v2`; `/health`, `/ready`, and `/openapi.json` are unversioned. `GET /v2` publishes safe first-class Registry service metadata and visible derived capabilities. Lists accept bounded `pageSize` and a client-opaque, authenticated-encrypted `cursor` and return `{items, pageInfo: {nextCursor}, meta}` with nullable `nextCursor`. Cursor confidentiality prevents filters and keyset-order values from bypassing field minimization; integrity binds revisions, operation, selected representation and disclosure profile, filters, fixed order, field set, authorization context, and expiry; each page is reauthorized. Single reads and resolved lookups return `{data, meta}`. No caller sorting exists. | -| Query and serialization minimization | Relay may read the complete fixed reviewed projection so it can validate the authoritative Record before disclosure. Unrequested and hidden columns are never serialized. Required null, wrong type, noncanonical value, transform-input failure, or size failure releases nothing and returns value-free `503 source.unavailable` for read, list, and lookup. Ordinary JSON and JSON-LD disclose the same Registry Core identity and selected domain values with deterministic property order. JSON-LD adds the generated context, a derived `@id`, and the resource semantic class as `@type` without replacing `recordIdentifier`. Cacheable responses require a public selected representation, public processing handling, and a snapshot; their strong ETag binds exact selected-profile bytes, `Vary: Accept, Authorization`, `If-None-Match`, and `304`. Other responses are `no-store` and have no ETag. | -| Semantic contract | Every resource and property has a stable local semantic identity, datatype, cardinality, label, and description. `relayctl` can generate reviewed starter semantics, JSON-LD context, permitted-representation JSON Schema and SHACL, full-record validation schema and SHACL, and codelist scaffolding without requiring prior semantic-web expertise. The representation artifacts require Registry Core and validate selectable domain properties only when present; full-record artifacts retain source requiredness. `semanticModelReference` resolves to the generated vocabulary/model and the context is linked separately. Generated suggestions are visibly non-authoritative until accepted. | +| Closed operation model | Resources compile only declared list, identifier-read, named exact-lookup, and named Point-bbox search operations. A list's operation-owned query shape determines whether enumeration is permitted; absence of list means no list enumeration. A Point-bbox search is a separate named operation with a required bounded `bbox`, its own order, pagination, and access profiles; list and search rights never imply one another. Collection filters are direct publisher-defined camelCase query parameters, typed, non-personal, and exact-equality only. Transformed properties cannot be filters or fixed-order keys; queryable derived values must be reviewed pre-derived source properties. Any non-empty subset of declared filters is valid, and the contract separately permits or forbids unfiltered access. `pageSize`, `cursor`, `fields`, `accessProfile`, and `formatProfile` are reserved. Lookups have complete bounded body inputs and exactly zero or one disclosed result. Callers cannot add SQL, source columns, joins, expressions, operators, paths, projection expressions, sort orders, or page traversal. | +| Access-profile selection and requester minimization | Every operation has a finite ordered `accessProfiles` map and exactly one explicit `defaultAccessProfile`. If any access profile is public, the default must be public so omission is truthful in public OpenAPI and anonymous clients never select a hidden protected default. The direct `accessProfile` parameter accepts exactly one non-empty compiled identifier; absence selects the default. Relay authenticates a supplied bearer before selection and authorizes only the selected access profile. Malformed, repeated, or empty selection is `400 request.access_profile_invalid`; a syntactically valid unknown name, an anonymous explicit request for a protected name, and a valid principal without the selected access-profile scope receive the same concealed `404 resource.not_found` outcome; purpose or row-binding denial after scope selection is `403 consultation.denied`. No request falls back to another access profile or reaches source access after refusal. `fields` may select only a non-empty duplicate-free subset of that selected profile. Registry Core remains present. Unknown, internal, source-column, cross-profile, or malformed field selections fail before source access and cannot change predicates, bindings, transforms, validation, authorization, effective handling, audit, quota, metadata, or cache posture. Wire format and optional `formatProfile` are independent serialization choices after authorization and cannot widen disclosure. | +| HTTP and pagination | Business routes are under `/v2`; `/health`, `/ready`, and `/openapi.json` are unversioned. `GET /v2` publishes safe first-class Registry service metadata and visible derived capabilities. Lists and named searches accept bounded `pageSize` and a client-opaque, authenticated-encrypted `cursor` and return `{items, pageInfo: {nextCursor}, meta}` with nullable `nextCursor`. Cursor confidentiality prevents filters and keyset-order values from bypassing field minimization; integrity binds revisions, exact operation, selected access profile and disclosure profile, filters or bbox, fixed order, field set, authorization context, wire format, format profile, and expiry; each page is reauthorized. Single reads and resolved lookups return `{data, meta}`. No caller sorting exists. | +| Query and serialization minimization | Relay may read the complete fixed reviewed projection so it can validate the authoritative Record before disclosure. Unrequested and hidden columns are never serialized. Required null, wrong type, noncanonical value, transform-input failure, or size failure releases nothing and returns value-free `503 source.unavailable` for read, list, lookup, and search. Ordinary JSON and JSON-LD disclose the same Registry Core identity and selected domain values with deterministic property order. JSON-LD adds the generated context, a derived `@id`, and the resource semantic class as `@type` without replacing `recordIdentifier`. Cacheable responses require a public selected access profile, public processing handling, and a snapshot; their strong ETag binds exact response bytes, `Vary: Accept, Authorization`, `If-None-Match`, and `304`. Other responses are `no-store` and have no ETag. | +| Semantic contract | Every resource and property has a stable local semantic identity, datatype, cardinality, label, and description. `relayctl` can generate reviewed starter semantics, JSON-LD context, permitted access-profile JSON Schema and SHACL, full-record validation schema and SHACL, and codelist scaffolding without requiring prior semantic-web expertise. Access-profile artifacts require Registry Core and validate selectable domain properties only when present; full-record artifacts retain source requiredness. `semanticModelReference` resolves to the generated vocabulary/model and the context is linked separately. Generated suggestions are visibly non-authoritative until accepted. | | External semantic alignment | Optional mappings to SEMIC, PublicSchema, schema.org, or another profile are curated, relation-qualified, versioned, and digest-pinned. Relay fetches no vocabulary and performs no inference at request time. Mapping changes appear in change-impact reports and cannot silently widen disclosure. | | Identification, classification, and review | The existing axes remain exact: `semanticTerm`, `privacy`, `institutional`, `handling`, `status`, and `provenanceRef`. `relayctl` identification is offline, schema-only, deterministic, explainable, and value-free; it reads no source values and no candidate self-approves. Every property and every processed source-view column has an effective reviewed classification. `sourceColumnClassifications` is explicit and complete for every multiply-bound column. The reviewed `ClassificationReview` at `classifications.provenanceRef` digest-binds Registry identity and the classification inventory; generated review additionally binds its accepted report and exact rule pack. Missing, suggested, uncertain, stale, or tampered review fails production compilation. Manual and imported review remain first-class. | -| Processing versus disclosure handling | Resource defaults reduce repetition; compilation expands defaults and explicit overrides before validation. Processing handling is the maximum across Registry Core, direct output, transform input, selector, filter, order, and row-binding source columns. Disclosure handling is the maximum across serializable properties for the selected representation. Authentication, audit, cache, source controls, and public eligibility use processing handling. A public representation may not transform a non-public raw column; publishers create a reviewed pre-derived public view column when that is appropriate. Handling is one of ordered `public`, `internal`, `confidential`, or `restricted`; non-public processing requires authentication, scope, `no-store`, and durable value-free audit, and restricted data cannot be listed. Purpose and row binding remain explicit access constraints. | -| Authentication and issuers | Relay acts as an OAuth 2.0 JWT resource server for protected operations and Version one configures exactly one issuer per Registry deployment. Relay strictly verifies issuer, audience, token type, algorithm, key, time, client or subject, token identifier, and scope claims. Invalid credentials and omitted credentials on a protected default operation return safe registry-wide `401` responses. An anonymous explicit request for a protected representation and a valid principal lacking the selected operation or representation scope receive the same `404 resource.not_found` as an unknown resource or operation; after the scope selects the representation, insufficient purpose or authority returns `403 consultation.denied`. Anonymous access exists only on representations explicitly compiled as public. | -| Operation authorization | List, read, and named lookup use distinct registered scopes that are unique across the Registry contract. Trusted purpose and authority-to-row binding are optional compiled constraints and can come only from the resolved principal or a direct verified scalar claim. Caller filters or headers never create authority. A lookup-only client cannot enumerate or perform identifier reads, even when another client can use those operations on the same deployment. | +| Processing versus disclosure handling | Resource defaults reduce repetition; compilation expands defaults and explicit overrides before validation. Processing handling is the maximum across Registry Core, direct output, transform input, selector, filter, spatial carrier, order, and row-binding source columns. Disclosure handling is the maximum across serializable properties for the selected access profile. Authentication, audit, cache, source controls, and public eligibility use processing handling. A public access profile may not transform a non-public raw column; publishers create a reviewed pre-derived public view column when that is appropriate. Handling is one of ordered `public`, `internal`, `confidential`, or `restricted`; non-public processing requires authentication, scope, `no-store`, and durable value-free audit, and restricted data cannot be listed. Purpose and row binding remain explicit access constraints. | +| Authentication and issuers | Relay acts as an OAuth 2.0 JWT resource server for protected operations and Version one configures exactly one issuer per Registry deployment. Relay strictly verifies issuer, audience, token type, algorithm, key, time, client or subject, token identifier, and scope claims. Invalid credentials and omitted credentials on a protected default operation return safe registry-wide `401` responses. An anonymous explicit request for a protected access profile and a valid principal lacking the selected operation or access-profile scope receive the same `404 resource.not_found` as an unknown resource or operation; after the scope selects the access profile, insufficient purpose or authority returns `403 consultation.denied`. Anonymous access exists only on access profiles explicitly compiled as public. | +| Operation authorization | List, read, named lookup, and named search use distinct registered scopes that are unique across the Registry contract. Trusted purpose and authority-to-row binding are optional compiled constraints and can come only from the resolved principal or a direct verified scalar claim. Caller filters or headers never create authority. A lookup-only or search-only client cannot synthesize list or identifier-read access, even when another client can use those operations on the same deployment. | | Optional Mint pairing | Relay accepts a conforming token from an external authorization server without Mint. A Mint deployment may be paired when it emits the same Relay audience, operation scopes, and optional authority claims from server-side grants. Relay has no Mint runtime dependency or Mint-specific authentication branch. Mint changes and a Mint integration journey do not block the core Relay V1 acceptance path. | | Lookup containment | Sensitive selectors use a bounded request body, are bound rather than rendered, and never appear in URLs, errors, logs, metrics, traces, audit, or responses. No match, ambiguity, policy-hidden record, and unknown or protected identifier share one `404` outcome with the same Registry Stack problem type, code, detail, schema, and headers. Only independently generated trace correlation may differ. An invalid selected source row is a value-free `503 source.unavailable`; invalid request syntax is a value-free bounded request error. Rate and concurrency limits make consultation abuse observable and bounded. | | Validation and failure | Every selected row and transform input is validated before release. Relay never skips, coerces, truncates, or partially releases an invalid row to preserve success. Errors use the fixed Registry Stack status/code catalog and derived type URIs plus `traceId`, exactly the 32-lowercase-hex trace ID of the effective valid or server-generated W3C Trace Context. Caller-supplied `tracestate` is never propagated. Problems contain no field-error array, SQL, paths, schema internals, selector values, source values, token material, or subject identifiers. Draft GovStack error namespaces are not used. Required audit or other release-gate failure prevents disclosure. | | Unsigned response boundary | Relay responses are not signed. TLS and access-token verification protect the live exchange, while revisions, ETags, provenance, and tamper-evident audit support accountability without being described as signatures. Evidence can consume a fixed Relay lookup when a portable signed minimum-disclosure assertion is required. | -| Audit and provenance | Every public or protected data request processed by Relay durably records either a refusal before returning or a pre-source attempt followed by one terminal release, unresolved, or source-failed outcome. Durable audit gates source access and response release. Events carry stable identifiers for Registry, resource, operation, access-rule revision, optional purpose, row-boundary kind, representation, disclosure profile, selected-property set or digest, processing handling, disclosure handling, transform identifiers, contract revision, and truthful source revision. Terminal audit covers exact held selected-profile bytes before release. Anonymous calls record an anonymous principal kind. Audit contains no tokens, selector values, source values, response values, SQL, or raw subject identifiers. The safeguards report names public shared-cache hits as outside Relay observation. | -| Metadata visibility and per-profile artifacts | Registry service identity is public. Other resource, capability, OpenAPI, semantic, classification, processing, and operational metadata is `public`, `operation-bound` behind the same static gate as the operation and representation whose Record links it, or `operator-only` in package/CLI with no HTTP route. Public metadata never inventories a protected representation identifier, profile schema, SHACL shape, JSON-LD context, semantic model, classification, processing description, or OpenAPI path. The package contains every profile's artifacts and full OpenAPI; `/openapi.json` is a deterministic safe public projection. Compilation fails if any successful Record audience cannot resolve a safe operation-bound projection of its exact profile schema and semantic model. | +| Audit and provenance | Every public or protected data request processed by Relay durably records either a refusal before returning or a pre-source attempt followed by one terminal release, unresolved, or source-failed outcome. Durable audit gates source access and response release. Events carry stable identifiers for Registry, resource, operation, access-rule revision, optional purpose, row-boundary kind, access profile, disclosure profile, selected-property set or digest, processing handling, disclosure handling, transform identifiers, contract revision, and truthful source revision. Terminal audit covers exact held selected-profile bytes before release. Anonymous calls record an anonymous principal kind. Audit contains no tokens, selector or bbox values, source values, response values, SQL, or raw subject identifiers. The safeguards report names public shared-cache hits as outside Relay observation. | +| Metadata visibility and per-profile artifacts | Registry service identity is public. Other resource, capability, OpenAPI, semantic, classification, processing, and operational metadata is `public`, `operation-bound` behind the same static gate as the operation and access profile whose Record links it, or `operator-only` in package/CLI with no HTTP route. Public metadata never inventories a protected access-profile identifier, profile schema, SHACL shape, JSON-LD context, semantic model, classification, processing description, or OpenAPI path. The package contains every profile's artifacts and full OpenAPI; `/openapi.json` is a deterministic safe public projection. Compilation fails if any successful Record audience cannot resolve a safe operation-bound projection of its exact profile schema and semantic model. | | Freshness and caching | Snapshot and live responses expose truthful, profile-specific revision and cache behavior. Every public snapshot response uses `Cache-Control: public, no-cache`, a strong exact-byte ETag, and revalidation. Every non-public or live response is `no-store`. `Vary: Authorization` prevents an anonymous cached `200` from serving a request with an invalid bearer. No response implies a stable cross-request snapshot, and field subsets cannot collide. | | Generated contracts | OpenAPI 3.1, JSON Schema, SHACL, JSON-LD contexts, codelists, and capability discovery are generated reproducibly from the compiled contract. Full and public OpenAPI projections have drift checks. No artifact is generated from the obsolete Digital Registries OpenAPI. | | Standards alignment | A concise maintained note pins the reviewed Digital Registries and API Design Guide drafts, maps adopted concepts and Consultation patterns, and records intentional gaps and rejected rules. It uses alignment language only, never conformance or certification. Machine-readable alignment reports, GovStack linting, Registry Manifest projection, and DPV generation are later optional tooling. | -| `relayctl` adopter journey | An adopter can initialize a project, inspect a SQLite schema without values by default, generate starter semantics, deterministic identification, classification inventory, processed-versus-disclosed representation report, contextual findings, and a review sidecar starter; validate, generate artifacts, run fixtures, inspect a semantic/classification/representation diff, and package a deployment without editing Rust. Generated output defaults below `generated/{reports,governance}`. A generated-review project copies its accepted report to `reports/identification-report.json` and binds it from `governance/classification-review.yaml`; imported and manual projects need no generated report. `relayctl` uses the same Relay compiler and fixture library as `relay` and implements no second product semantics. | +| `relayctl` adopter journey | An adopter can initialize a project, inspect a SQLite schema without values by default, generate starter semantics, deterministic identification, classification inventory, contextual findings, and a review sidecar starter; validate with `relayctl check --explain`, generate the same canonical value-free `reports/operation-explanation.json`, run fixtures, inspect a semantic/classification/access-profile/query diff, and package a deployment without editing Rust. The explanation names operation paths, query capability and value-free reasons, access requirements, processed columns, disclosure, transforms, wire formats, and cache posture without source or allowed authorization-claim values. Generated output defaults below `generated/{reports,governance}`. A generated-review project copies its accepted report to `reports/identification-report.json` and binds it from `governance/classification-review.yaml`; imported and manual projects need no generated report. `relayctl` uses the same Relay compiler and fixture library as `relay` and implements no second product semantics. | | Change impact and safeguards | A contract diff identifies new properties, wider operations or filters, relaxed classification, changed disclosure profiles, removed row bindings, expanded scopes or purposes, changed metadata visibility, source-view changes, and semantic mapping changes. Each applicable DPI safeguard is linked to a concrete mechanism, enforcement point, negative test, evidence artifact, and named institutional responsibility. No certification claim is generated. | | Operability | Unauthenticated `/health` reports only liveness and `/ready` reports only ability to serve the compiled Registry, each as minimal `application/json` on `200`, `no-store`, and safe Registry Stack Problem `503` on failure. Neither exposes Registry or source details. Startup, shutdown, bounded concurrency, audit durability, issuer-key refresh, source unavailability, schema drift, and live publisher replacement have documented and tested behavior. Relay emits structured value-free lifecycle logs and bounded request outcomes using only a fixed method class, route template, status, latency, and trace identifier. Version one has no `/metrics` route or in-process metrics registry; operators derive aggregate metrics outside Relay from these logs and durable audit without protected values or high-cardinality subject labels. | | Verification evidence | Focused positive, negative, boundary, and non-disclosure tests pass for every security-sensitive behavior. Formatting, package check, Clippy with warnings denied, package tests, workspace tests, dependency policy, contract drift, exposure inventory, source neutrality, config-key-path, and reproducible-generation gates pass on one revision. CI path selection is itself tested for every new owning path. | @@ -90,18 +90,18 @@ For each of the three coequal registries: 2. Registry identity, authority, scope, alignment targets, and derived Consultation capabilities are correct; 3. offline fixtures and the real HTTP service return the same semantic result; 4. every returned Record has valid Registry Core context, and `recordedAt` and `revisionIdentifier` come from the source view; -5. ordinary JSON and JSON-LD are data-equivalent after removing the JSON-LD `@id` and `@type`; every returned Record validates against the exact generated permitted-representation JSON Schema, its operation/representation binding resolves the corresponding generated SHACL artifact, and the actual JSON-LD response expands to an RDF graph whose class, IRI nodes, predicates, and datatypes match that SHACL shape and the compiled model; -6. default and explicitly requested representations, plus at least two valid `domainData` subsets within a selected representation, succeed while Registry Core remains complete; -7. an unknown property, source-column name, cross-profile property, duplicate property, malformed selection, malformed/repeated representation, unknown representation, and denied selected representation fail without source or value leakage or fallback; +5. ordinary JSON and JSON-LD are data-equivalent after removing the JSON-LD `@id` and `@type`; every returned Record validates against the exact generated permitted access-profile JSON Schema, its operation/access-profile binding resolves the corresponding generated SHACL artifact, and the actual JSON-LD response expands to an RDF graph whose class, IRI nodes, predicates, and datatypes match that SHACL shape and the compiled model; +6. default and explicitly requested access profiles, plus at least two valid `domainData` subsets within a selected access profile, succeed while Registry Core remains complete; +7. an unknown property, source-column name, cross-profile property, duplicate property, malformed selection, malformed/repeated access profile, unknown access profile, and denied selected access profile fail without source or value leakage or fallback; 8. invalid selected source rows fail the whole response closed with value-free `503 source.unavailable`; every Registry proves at least one such refusal, and the coequal suite covers wrong type, missing required value, extra unexpected value, and excessive size; 9. restarting with identical inputs reproduces the same compiled contract and generated artifacts; 10. a schema or governed-contract change is detected and cannot silently widen the active API; 11. full packaged OpenAPI, safe public OpenAPI, per-profile semantic, schema, SHACL, JSON-LD, classification, processing, codelist, and capability artifacts reproduce byte for byte. -12. schema-only identification reports, classification inventories, processed-versus-disclosed representation reports, contextual findings, and review-sidecar staleness/tamper refusals are deterministic and value-free. +12. schema-only identification reports, classification inventories, canonical operation explanations, contextual findings, and review-sidecar staleness/tamper refusals are deterministic and value-free; `check --explain` and `generate` return the same explanation. Shared security acceptance additionally proves that: -1. a response and its emitted value-free audit correlate through trace and request-operation identifiers and agree on Registry, resource, compiled operation, contract, selected representation and disclosure profile, selected properties, processing/disclosure handling, row-boundary kind, and truthful source revision; +1. a response and its emitted value-free audit correlate through trace and request-operation identifiers and agree on Registry, resource, compiled operation, contract, selected access profile and disclosure profile, selected properties, processing/disclosure handling, row-boundary kind, and truthful source revision; 2. audit never records response bytes, response digests, Record identifiers, raw subject identifiers, or fixture canaries; 3. Problems do not contain fixture canaries, trace headers carry only Relay-validated fixed identifiers, and operational log dimensions cannot contain request paths, identifiers, query values, headers, bodies, selectors, or principals; 4. adopter reports and generated or packaged artifacts pass fixture-canary scans. @@ -127,21 +127,24 @@ value-free operational log dimensions. - anonymous paginated list and identifier read over a captured snapshot; - a separate public registered-premises resource with a classified, selectable CRS84 Point assembled from reviewed longitude and latitude columns; -- exact inclusive bounded `bbox` search, boundary inclusion, malformed, - out-of-range, oversize, and antimeridian refusal, deterministic pagination, - and cursor rejection when bbox, governed representation, response format, or - GeoJSON profile changes; +- exact named inclusive bounded `bbox` search, required-bbox enforcement, + boundary inclusion, malformed, out-of-range, oversize, and antimeridian + refusal, deterministic pagination, and cursor rejection when operation, + bbox, access profile, wire format, or format profile changes; +- the public search and separately protected list prove that list and search + scopes do not imply one another; a protected search access profile is + independently concealed when denied or unknown; - equivalent governed JSON, JSON-LD, RFC 7946 GeoJSON, and JSON-FG responses, including a requested field subset that omits geometry; -- no GeoJSON negotiation on a nonspatial operation or a governed representation +- no GeoJSON negotiation on a nonspatial operation or an access profile that omits geometry, and no disclosure of an invalid coordinate row or its values; - `pageSize`, first page, cursor page, and nullable `pageInfo.nextCursor` behavior; - no filter when allowed, each declared direct camelCase exact filter, a subset of declared filters, unknown filter, unsupported operator, and attempted arbitrary sort; - deterministic ordering and pagination with no duplicate or missing record across the unchanged snapshot; - public field subset, JSON-LD context, SEMIC mapping artifact, SHACL, and codelist validation; -- public default list/read can request only public representations; protected registrar representation metadata, schema, SHACL, JSON-LD, processing, and OpenAPI are absent from public discovery; -- a public representation reads only the reviewed pre-derived public view, never a non-public raw column, and a profile-bound cursor or ETag cannot cross into another representation; +- public default list/read can request only public access profiles; protected registrar access-profile metadata, schema, SHACL, JSON-LD, processing, and OpenAPI are absent from public discovery; +- a public access profile reads only the reviewed pre-derived public view, never a non-public raw column, and a profile-bound cursor or ETag cannot cross into another access profile; - snapshot digest, path replacement, unsafe sidecar, write attempt, and schema mismatch failures. - `consultation.list`, `consultation.retrieve`, and the bounded point `consultation.search` discovery with no unsupported family claim. @@ -149,7 +152,7 @@ value-free operational log dimensions. ### Civil-event registry cases - protected identifier read and named exact verification lookup, with collection listing absent; -- registrar and supervisory representations are selected explicitly, are independently scoped, and never fall back; neither read nor lookup scope can synthesize the other; +- registrar and supervisory access profiles are selected explicitly, are independently scoped, and never fall back; neither read nor lookup scope can synthesize the other; - the civil journey proves `date-precision` (`year` and `year-month`) with distinct output terms/types; focused transform and real-router tests reject null, noncanonical, incompatible, and oversized source values with value-free source failure; - the external-issuer path is complete; a later optional Mint pairing must traverse the same verifier and access-decision path; - no match, ambiguity, and a jurisdiction-hidden row collapse to the unresolved lookup outcome; an invalid event record or transform input fails as value-free `503 source.unavailable`, while wrong purpose and wrong jurisdiction binding retain their distinct governed refusal behavior; @@ -157,7 +160,7 @@ value-free operational log dimensions. suitable for a future Evidence integration, without adding signing behavior to Relay; a real Evidence pairing is a separate non-blocking journey. - the same Registry Core fields remain present under both operation-specific disclosure profiles. -- no list route exists, including when a caller asks for a representation identifier. +- no list route exists, including when a caller asks for an access-profile identifier. ### Classification-review methods diff --git a/products/relay-v2/IMPLEMENTATION.md b/products/relay-v2/IMPLEMENTATION.md index dc646d55d..a3a33fc1b 100644 --- a/products/relay-v2/IMPLEMENTATION.md +++ b/products/relay-v2/IMPLEMENTATION.md @@ -26,7 +26,7 @@ complete and green, but no partial milestone is described as Relay V2 complete. | Package | Boundary | |---|---| -| `registry-relay-v2` | New library and final `relay` binary. Owns the strict Relay contract, compiler, generated artifacts, fixture kernel, access and disclosure plans, representations, HTTP service, and Relay event/problem vocabularies. It has no dependency on Relay V1. | +| `registry-relay-v2` | New library and final `relay` binary. Owns the strict Relay contract, compiler, generated artifacts, fixture kernel, access and disclosure plans, wire formats, HTTP service, and Relay event/problem vocabularies. It has no dependency on Relay V1. | | `registry-relayctl` | New `relayctl` binary. Owns authoring presentation and orchestration only. It links the shared Relay compiler library and never reimplements its rules. | | `registry-platform-sqlite` | New product-neutral SQLite security boundary shared by Evidence and Relay. It is SQLite-specific, not a generic storage abstraction. | | `products/relay-v2` | Canonical concept, contract schemas, examples, fixtures, generated artifacts, compact scenario and security traceability, alignment note, and drift scripts. | @@ -69,14 +69,14 @@ owns: - resources, Registry Core source bindings, URL-safe camelCase property keys, published properties, datatypes, source requiredness, codelists, labels, descriptions, and local semantic IRIs; -- compiled list, read, or named exact-lookup operations; query shape remains - operation-owned while each operation declares one `defaultRepresentation` - and finite ordered `representations` with representation-owned `access` and +- compiled list, read, named exact-lookup, or named Point-bbox search operations; query shape remains + operation-owned while each operation declares one `defaultAccessProfile` + and finite ordered `accessProfiles` with access-profile-owned `access` and `disclosureProfile`; list presence derives the enumeration posture; - direct typed equality filters, explicit unfiltered permission, fixed ordering, page bounds, lookup selectors, and query limits; - reusable disclosure profiles whose `properties` lists are selected only by a - compiled representation; callers may narrow only the selected profile with + compiled access profile; callers may narrow only the selected profile with `fields`, never cross profiles; - privacy, institutional, and `public`/`internal`/`confidential`/`restricted` technical handling classifications for every published property and reviewed @@ -107,20 +107,21 @@ not part of the Version one contract. `sourceRequired` governs validation of the complete authoritative source Record. It does not make a property mandatory in every requester-minimized -representation. The compiler emits separate full-record validation and -permitted-representation artifacts. The latter requires Registry Core and +access profile. The compiler emits separate full-record validation and +permitted access-profile artifacts. The latter requires Registry Core and validates selectable `domainData` properties when present; the former preserves source requiredness and full SHACL cardinality. The spatial profile is deliberately closed. `primaryGeometry` has one name, semantic term, classification, requiredness, exact CRS84 identifier, and two reviewed SQLite source columns, `longitudeColumn` and `latitudeColumn`. It -serializes only a validated GeoJSON Point. An opted list's `spatialQuery.bbox` -sets maximum longitude and latitude spans in whole degrees. The existing -operation `representations` map continues to own access and disclosure. JSON -and JSON-LD are response formats for every selected governed representation; -GeoJSON is derived only when that representation discloses the primary -geometry, with `profile=rfc7946` or `profile=jsonfg` selecting its profile. +serializes only a validated GeoJSON Point. A named `point-bbox` search sets +maximum longitude and latitude spans in whole degrees, requires exactly one +`bbox`, and owns its order, pagination, and access profiles. Lists do not +accept `bbox`, and a list scope cannot synthesize a search. JSON and JSON-LD +are wire formats for every selected access profile; GeoJSON is derived only +when that profile discloses the primary geometry, with +`formatProfile=rfc7946` or `formatProfile=jsonfg` selecting its serialization profile. Because bbox is a collection-query selector, its primary geometry must have effective `privacy: non-personal` classification. There is no storage abstraction, dynamic SQLite extension, GeoPackage parser, @@ -184,19 +185,19 @@ Registry Manifest projection is deferred portability tooling. Source columns, access rules, scopes, disclosure, classifications, processing constraints, and limits remain Relay-owned and never enter Manifest. -### Identification, review, and governed representation increment +### Identification, review, and governed access-profile increment -The compiler owns the closed representation model and never receives +The compiler owns the closed access-profile model and never receives caller-authored transforms or policy expressions. It validates exactly one -`defaultRepresentation` against each finite operation map; compiles access, +`defaultAccessProfile` against each finite operation map; compiles access, disclosure, processing handling, disclosure handling, transform inventory, and -artifact identity per representation; and carries operation query shape, +artifact identity per access profile; and carries operation query shape, filters, selectors, order, pagination, and quotas outside that map. `sourceColumnClassifications` remains resource-owned. The compiler requires an explicit complete reviewed classification for a transformed or multiply-bound column, accounts for all Registry Core and hidden processing columns, and -rejects a public representation that processes a non-public column. It accepts +rejects a public access profile that processes a non-public column. It accepts only `partial-string` and `date-precision`: their pure implementation is separate from parsing, SQL, authorization, and serialization. The marker is the fixed Relay constant `***`; output types are `string`, `year`, or @@ -212,11 +213,18 @@ authority. The compiler's artifact and package paths carry every profile for operator review while public projection includes only public-visible profile artifacts. -The HTTP layer parses `representation` once before source access, authenticates +`relayctl check --explain` returns the compiler-owned canonical operation +explanation. `generate` writes the same bytes to +`reports/operation-explanation.json`. It lists operation paths, query +capabilities and categorical reasons, access profiles, processed columns, +disclosure, transforms, wire formats, and cache posture without source values +or allowed authorization-claim values. + +The HTTP layer parses `accessProfile` once before source access, authenticates any supplied bearer before public default selection, authorizes the exact profile, then narrows `fields` within it. Cursor and public ETag construction include profile identity and transform inventory. Protected, non-public -processing, and live responses are `no-store`. Audit adds representation, +processing, and live responses are `no-store`. Audit adds access profile, disclosure, selected properties, both handling levels, and transform IDs, but no values; terminal audit gates the exact serialized bytes. @@ -278,6 +286,7 @@ is deterministic for validators, but member order is not a client contract: "operationIdentifier": "registeredBusiness.list", "family": "consultation", "pattern": "list", + "accessProfile": "public-register", "disclosureProfile": "public-register", "contractRevision": "sha256:...", "sourceRevision": { @@ -296,7 +305,7 @@ is deterministic for validators, but member order is not a client contract: ``` `family` is always `consultation`; `pattern` is `retrieve`, `list`, or -`search`. A list constrained by declared point bbox is `search`. +`search`. A named Point-bbox operation is `search`; a list remains `list`. `sourceRevision.status` is `versioned` or `unversioned`; `value` is null only for unversioned live data. `selectedFields` is always present in contract order, including when the caller omitted `fields`. No other member is @@ -305,7 +314,7 @@ nullable. `semanticModelReference` points to the local vocabulary/model while row-binding value, raw principal, token, SQL, or protected source value. Where caching is allowed, a strong ETag hashes the exact response bytes, so -field subsets and the two representations have different ETags. `Vary: Accept, +field subsets and access profiles have different ETags. `Vary: Accept, Authorization`, `If-None-Match`, and `304` are part of the GET contract. Non-public and unversioned-live responses are `no-store` and emit no ETag. @@ -341,13 +350,13 @@ the bound SHACL shape, and maps transport-only `meta` and `pageInfo` to null so they do not become domain triples. Ordinary JSON retains the shapes above without `@context`, `@id`, or `@type`. -When the selected governed representation discloses the resource's primary +When the selected access profile discloses the resource's primary geometry, `application/geo+json` returns an RFC 7946 -Feature for a single Record and FeatureCollection for a list. Feature +Feature for a single Record and FeatureCollection for a list or named search. Feature `properties` carries Registry Core and selected non-spatial domain fields; `geometry` is the selected Point or `null` when the requester omitted it. Together they preserve the ordinary governed disclosure. The default -`profile=rfc7946` has no JSON-FG additions. `profile=jsonfg` adds only bounded +`formatProfile=rfc7946` has no JSON-FG additions. `formatProfile=jsonfg` adds only bounded JSON-FG conformance and feature-type metadata from the same compiled operation. ### HTTP binding and capabilities @@ -361,9 +370,10 @@ GET /openapi.json GET /v2 GET /v2/resources?pageSize=...&cursor=... GET /v2/resources/{resource} -GET /v2/resources/{resource}/records?pageSize=...&cursor=...&=...&fields=... -GET /v2/resources/{resource}/records/{recordIdentifier}?fields=... -POST /v2/resources/{resource}/lookups/{lookup}?fields=... +GET /v2/resources/{resource}/records?pageSize=...&cursor=...&=...&accessProfile=...&fields=...&formatProfile=... +GET /v2/resources/{resource}/records/{recordIdentifier}?accessProfile=...&fields=...&formatProfile=... +POST /v2/resources/{resource}/lookups/{lookup}?accessProfile=...&fields=...&formatProfile=... +GET /v2/resources/{resource}/searches/{search}?bbox=...&pageSize=...&cursor=...&accessProfile=...&fields=...&formatProfile=... GET /v2/artifacts/{artifactIdentifier} ``` @@ -414,10 +424,11 @@ the operations included in resource metadata; it is not configured separately. List filters are direct declared camelCase query parameters, exact-equality only, non-personal, unique, and cannot be named `pageSize`, `cursor`, -`fields`, `bbox`, or `profile`. Any non-empty subset of declared filters is -valid. An opted point list may accept exactly one +`fields`, `accessProfile`, `formatProfile`, or `bbox`. Any non-empty subset of declared filters is +valid. A named Point-bbox search requires exactly one `bbox=minLon,minLat,maxLon,maxLat` predicate. It is finite, CRS84-range-checked, -inclusive, non-wrapping, and bounded by compiled spans before source access. The operation +inclusive, non-wrapping, and bounded by compiled spans before source access. A +list rejects `bbox`. The operation explicitly declares whether the empty subset is allowed with `allowUnfiltered`. Transformed properties are response-only and fail compilation when named as a filter or fixed-order key. A queryable derived value must be a separately @@ -425,27 +436,22 @@ reviewed pre-derived source property. `pageSize` is bounded by the operation default and maximum. Ordering is fixed with `recordIdentifier` as the unique tie-breaker. The client-opaque authenticated-encrypted cursor binds contract and source revisions, operation, -selected representation and disclosure profile, filters, fixed order, fields, -authorization-relevant context, and expiry. Every page +selected access profile and disclosure profile, filters or bbox, fixed order, +fields, wire format, format profile, authorization-relevant context, and +expiry. Every page is reauthorized. Authenticated encryption prevents filters and keyset-order values from bypassing field minimization. A caller cannot sort, name a source column, add an operator, or traverse an uncompiled page. -Spatial context is issued in cursor version 2. During the bounded upgrade -window, Relay also accepts an integrity-valid version 1 cursor only as its -original nonspatial JSON query with no bbox or response-format profile. New -cursors are always version 2, and the ordinary expiry, contract, source, -operation, filter, field, order, and authorization bindings still apply. - -The first page accepts `pageSize`, `fields`, and declared filters. A -continuation request supplies exactly one `cursor` parameter and -no `pageSize`, `fields`, or filters; the cursor restores the immutable query +The first page accepts `pageSize`, `fields`, `accessProfile`, `formatProfile`, +and the complete declared query shape. A continuation request supplies exactly +one `cursor` and may repeat only the same `accessProfile`; the cursor restores the immutable query context. Repeating or changing first-page parameters with a cursor is `query.cursor_invalid`. Version 1 accepts one deployment quota with `requestsPerMinute` and `burst`. Relay maintains one bucket per compiled operation, shared by all of that -operation's representations. The state is bounded and in-process, so the +operation's access profiles. The state is bounded and in-process, so the declared deployment profile is one Relay replica per Registry. A multi-replica deployment must put a trusted ingress or shared limiter in front that enforces the same ceilings; distributed rate-limit state is outside the initial runtime. @@ -454,7 +460,7 @@ Compiled operations derive their capability mapping: - read: `consultation.retrieve`; - list: `consultation.list`; -- named exact lookup: constrained `consultation.search`. +- named exact lookup and named Point-bbox search: constrained `consultation.search`. The generated capability inventory includes Registry identity and authority, alignment targets, API binding version, operation and resource IDs, pattern @@ -494,8 +500,8 @@ No match, ambiguity, policy-hidden record, and unknown or protected identifier return the same `404` problem and headers. Only independently generated trace correlation may differ. An invalid selected source row is an authoritative source failure and returns the value-free `503 source.unavailable` problem. -Malformed requests, credentials, insufficient authority, unsupported -representation, body size, quota, internal failure, source failure, and audit +Malformed requests, credentials, insufficient authority, unsupported wire +format, body size, quota, internal failure, source failure, and audit failure use stable separate Registry Stack codes without reflecting input values. Problems are `no-store`. `401` includes `WWW-Authenticate`; Relay-owned `429` includes a coarse @@ -519,17 +525,17 @@ error array is emitted. |---|---:|---|---| | Malformed JSON, query syntax, or lookup body | 400 | `consultation.invalid_request` | `the consultation request is invalid` | | Invalid, empty, repeated, unknown, or non-public `fields` selection | 400 | `request.fields_invalid` | `field selection is invalid` | -| Malformed, empty, or repeated `representation` selection | 400 | `request.representation_invalid` | `representation selection is invalid` | +| Malformed, empty, or repeated `accessProfile` selection | 400 | `request.access_profile_invalid` | `access profile selection is invalid` | | Undeclared filter | 400 | `filter.unknown_field` | `filter is not declared for this operation` | | Invalid filter value or combination | 400 | `filter.invalid_value` | `filter value is invalid` | | Malformed, expired, stale, or differently bound cursor | 400 | `query.cursor_invalid` | `cursor is invalid for this query` | | Missing credential on a protected operation | 401 | `auth.missing_credential` | `a bearer access token is required` | | Invalid credential | 401 | `auth.invalid_credential` | `bearer access token validation failed` | -| Valid credential without the selected operation or representation scope | 404 | `resource.not_found` | `the requested resource was not found` | +| Valid credential without the selected operation or access-profile scope | 404 | `resource.not_found` | `the requested resource was not found` | | Insufficient purpose or row authority after scope selection | 403 | `consultation.denied` | `the consultation is not permitted` | -| Unknown or visibility-hidden resource, artifact, operation, or representation | 404 | `resource.not_found` | `the requested resource was not found` | +| Unknown or visibility-hidden resource, artifact, operation, or access profile | 404 | `resource.not_found` | `the requested resource was not found` | | Unknown, hidden, ambiguous, or policy-hidden Record outcome | 404 | `consultation.unresolved` | `the requested record was not resolved` | -| Unsupported response `Accept` | 406 | `representation.unsupported` | `the requested representation is not supported` | +| Unsupported response `Accept` or `formatProfile` | 406 | `format.unsupported` | `the requested format is not supported` | | Request body too large | 413 | `internal.payload_too_large` | `request body exceeds the configured limit` | | Request URI too long | 414 | `internal.uri_too_long` | `request URI exceeds the configured limit` | | Unsupported request body media type | 415 | `request.media_type_unsupported` | `request body must use application/json` | @@ -571,17 +577,17 @@ Protected operations accept only a registered JWT access-token profile: single-use claim; - principal resolved in order from `sub`, `client_id`, then `azp`, with a malformed higher-priority claim failing rather than falling through; -- one exact selected-representation scope plus any compiled purpose and row-binding claim. +- one exact selected-access-profile scope plus any compiled purpose and row-binding claim. The deployment has at most one issuer verification configuration. Its exact issuer is checked before its keys can authorize the token. A missing bearer is -allowed only for an explicitly public default representation. An anonymous -explicit request for a protected representation is concealed like an unknown -representation; an invalid bearer is never treated as anonymous. Caller purpose +allowed only for an explicitly public default access profile. An anonymous +explicit request for a protected access profile is concealed like an unknown +profile; an invalid bearer is never treated as anonymous. Caller purpose headers are rejected. Purpose and row authority come only from verified claims assigned by the authorization server. Every protected operation scope is non-empty and unique across the -Registry contract. Missing selected-representation scope is concealed as +Registry contract. Missing selected-access-profile scope is concealed as `resource.not_found` before source access. An authority row binding explicitly selects either the resolved principal or one direct verified scalar claim. Relay injects its hidden typed equality predicate; caller filters cannot satisfy or replace it. @@ -597,7 +603,7 @@ as a release gate: Audit sink failure returns `503` and prevents source access or response release at the relevant gate. Events contain stable Registry, resource, operation, -representation, access-rule, processing, disclosure, transform, +access profile, access-rule, processing, disclosure, transform, selected-property, handling, contract, and truthful source revision identifiers. They contain no token, selector, SQL, path, source or response value, or raw principal identifier. @@ -672,7 +678,7 @@ tests. ### 3. `relayctl` adopter workflow -- Add `registry-relayctl` with `init`, `inspect`, `check`, `generate`, `test`, +- Add `registry-relayctl` with `init`, `inspect`, `check --explain`, `generate`, `test`, `diff`, and `package`. - Call the shared Relay compiler and fixture library directly for schema inspection, checking, generation, fixtures, semantic diff, and packaging. @@ -691,7 +697,7 @@ Registry. No `registryctl` file or command changes. ### 4. HTTP service over the compiled kernel - Mount the already-proven offline kernel behind HTTP and add ETags, cursors, - list/read/lookup routes, Registry service metadata, resource metadata, + list/read/lookup/named-search routes, Registry service metadata, resource metadata, artifacts, health, readiness, and safe public OpenAPI. - Implement exact-lookup collapse, request and concurrency quotas, cursor reauthorization, schema drift refusal, live transaction consistency, and @@ -699,7 +705,7 @@ Registry. No `registryctl` file or command changes. - Keep route construction hardcoded from compiled operation kinds. Contract data chooses which routes exist but cannot supply arbitrary paths or SQL. -Gate: focused route, cursor, representation, caching, source-boundary, and +Gate: focused route, cursor, access-profile, wire-format, caching, source-boundary, and non-disclosure integration tests plus generated OpenAPI validation and drift. The maintained alignment note is reviewed here. A draft GovStack linter is not a Version one gate. diff --git a/products/relay-v2/README.md b/products/relay-v2/README.md index d24ebd7f8..518754e75 100644 --- a/products/relay-v2/README.md +++ b/products/relay-v2/README.md @@ -9,8 +9,9 @@ The initial boundary is intentionally narrow: - one Relay process serves one governed Registry; - SQLite is read-only and is the only source profile; -- an opted resource may expose one classified CRS84 Point and bounded exact - bbox search when that geometry is non-personal; +- an opted resource may expose one classified CRS84 Point and a named bounded + exact Point-bbox search when that geometry is non-personal; list and search + access remain independent; - resources are Record types within the Registry; - compiled operations map only to Consultation Retrieve, List, and constrained Search; @@ -48,5 +49,6 @@ products/relay-v2/scripts/test-http.sh Together they validate the product catalogs and configs, build each SQLite fixture in a temporary directory, run the canonical `relayctl test` journeys, -reproduce generated artifacts, enforce source neutrality, and exercise all +prove `relayctl check --explain` agrees with the generated canonical operation +explanation, reproduce generated artifacts, enforce source neutrality, and exercise all three deployments through the real Relay router. diff --git a/products/relay-v2/STANDARDS-ALIGNMENT.md b/products/relay-v2/STANDARDS-ALIGNMENT.md index 58ef28b6b..e3518be72 100644 --- a/products/relay-v2/STANDARDS-ALIGNMENT.md +++ b/products/relay-v2/STANDARDS-ALIGNMENT.md @@ -16,10 +16,11 @@ claim. The obsolete Digital Registries OpenAPI is not an input. | One Registry with named authority and authoritative scope | Authored once in `RegistryContract`, compiled into service metadata and package provenance. | | Consultation Retrieve | Identifier read is compiled only when the resource declares `read`. | | Consultation List | Deterministic list is compiled only when the resource declares `list`; pagination and filters are closed. | -| Consultation Search | A named exact lookup is the only accepted search-shaped operation. It returns one governed Record or the unresolved outcome. | -| Bounded spatial consultation | An opted point list with required bounded `bbox` is derived as `consultation.search`; it remains the same fixed consultation route and does not create an OGC API Features service. | +| Consultation Search | Named exact lookup and named Point-bbox search are the only accepted search-shaped operations. Exact lookup returns one governed Record or the unresolved outcome; Point-bbox returns a bounded collection. | +| Bounded spatial consultation | A named Point-bbox operation with required bounded `bbox` is derived as `consultation.search`; its access profiles are independent from list and it does not create an OGC API Features service. | | Registry semantics | Every resource and property has a stable local semantic identity; JSON-LD, JSON Schema, and SHACL artifacts are compiler outputs. | -| Governed representations | A compiled operation may expose only its finite reviewed representations, each with its own access, disclosure, semantic, schema, SHACL, JSON-LD, classification, and processing artifact. This is controlled publication, not content negotiation or dynamic ABAC. | +| Governed access profiles | A compiled operation may expose only its finite reviewed access profiles, each with its own access, disclosure, semantic, schema, SHACL, JSON-LD, classification, and processing artifact. `fields` can only narrow the selected profile. Wire format and optional format profile are independent serialization choices, not entitlement or dynamic ABAC. | +| Explainable contract | `relayctl check --explain` and generated `operation-explanation.json` expose one value-free compiler view of operation paths, query capabilities and reasons, access, disclosure, transforms, wire formats, and cache posture. | | Data governance review | Schema-only identification supplies deterministic review evidence. Classification remains Registry Authority review metadata and constrains compilation; it never grants an entitlement or becomes a remote runtime policy. | | Capability discovery | The public and protected inventories are derived from compiled operations and their visibility. | | API description | One full OpenAPI 3.1 document is package-only and one deterministic public subset is exposed at `/openapi.json`. | diff --git a/products/relay-v2/acceptance/business-registry/expected-http.yaml b/products/relay-v2/acceptance/business-registry/expected-http.yaml index 7f5ea8a4b..56f122238 100644 --- a/products/relay-v2/acceptance/business-registry/expected-http.yaml +++ b/products/relay-v2/acceptance/business-registry/expected-http.yaml @@ -9,6 +9,14 @@ authorizations: principal: synthetic-business-reader scopes: [registry:business:reader] claims: {} + premises-list: + principal: synthetic-premises-list-client + scopes: [registry:business:premises-list] + claims: {} + premises-search-registrar: + principal: synthetic-premises-search-client + scopes: [registry:business:premises-search-registrar] + claims: {} steps: - id: registry-discovery request: {method: GET, path: /v2} @@ -76,24 +84,24 @@ steps: request: method: GET path: /v2/resources/registered-business/records/BIZ-SYNTH-0001 - query: {representation: registrar, fields: "registrarLegalName,registrarNote"} + query: {accessProfile: registrar, fields: "registrarLegalName,registrarNote"} expect: status: 200 registryCoreRequired: true domainDataKeys: [registrarLegalName, registrarNote] cache: no-store - - id: registrar-representation-denied + - id: registrar-access-profile-denied authorizationFixture: business-unentitled request: method: GET path: /v2/resources/registered-business/records/BIZ-SYNTH-0001 - query: {representation: registrar} + query: {accessProfile: registrar} expect: {status: 404, code: resource.not_found} - - id: public-representation-unknown + - id: public-access-profile-unknown request: method: GET path: /v2/resources/registered-business/records/BIZ-SYNTH-0001 - query: {representation: registrar-private} + query: {accessProfile: registrar-private} expect: {status: 404, code: resource.not_found} - id: identifier-read-jsonld request: @@ -116,18 +124,66 @@ steps: - id: premises-first-page request: method: GET - path: /v2/resources/registered-premises/records + path: /v2/resources/registered-premises/searches/within-bbox query: {bbox: "100,13,101,14", pageSize: 2} expect: status: 200 itemCount: 2 nextCursor: non-null registryCoreRequired: true - domainDataKeys: [premisesIdentifier, businessRegistrationNumber, premisesName, location] - - id: premises-second-page + domainDataKeys: [premisesIdentifier, premisesName, location] + - id: premises-registrar-search + authorizationFixture: premises-search-registrar + request: + method: GET + path: /v2/resources/registered-premises/searches/within-bbox + query: {bbox: "100,13,101,14", accessProfile: registrar-premises, fields: "premisesIdentifier,businessRegistrationNumber"} + expect: + status: 200 + itemCount: 2 + registryCoreRequired: true + domainDataKeys: [premisesIdentifier, businessRegistrationNumber] + cache: no-store + - id: premises-search-access-profile-denied + authorizationFixture: business-unentitled + request: + method: GET + path: /v2/resources/registered-premises/searches/within-bbox + query: {bbox: "100,13,101,14", accessProfile: registrar-premises} + expect: {status: 404, code: resource.not_found} + - id: premises-search-access-profile-unknown + request: + method: GET + path: /v2/resources/registered-premises/searches/within-bbox + query: {bbox: "100,13,101,14", accessProfile: unknown-profile} + expect: {status: 404, code: resource.not_found} + - id: premises-list-authorized + authorizationFixture: premises-list request: method: GET path: /v2/resources/registered-premises/records + query: {pageSize: 2, fields: "premisesIdentifier,businessRegistrationNumber"} + expect: + status: 200 + itemCount: 2 + registryCoreRequired: true + domainDataKeys: [premisesIdentifier, businessRegistrationNumber] + cache: no-store + - id: premises-search-scope-cannot-list + authorizationFixture: premises-search-registrar + request: {method: GET, path: /v2/resources/registered-premises/records} + expect: {status: 404, code: resource.not_found} + - id: premises-list-scope-cannot-search + authorizationFixture: premises-list + request: + method: GET + path: /v2/resources/registered-premises/searches/within-bbox + query: {bbox: "100,13,101,14", accessProfile: registrar-premises} + expect: {status: 404, code: resource.not_found} + - id: premises-second-page + request: + method: GET + path: /v2/resources/registered-premises/searches/within-bbox query: {cursor: "$nextCursor:premises-first-page"} expect: status: 200 @@ -137,13 +193,13 @@ steps: - id: premises-boundary-point request: method: GET - path: /v2/resources/registered-premises/records + path: /v2/resources/registered-premises/searches/within-bbox query: {bbox: "101,14,101,14"} expect: {status: 200, itemCount: 1, registryCoreRequired: true} - id: premises-fields-omit-location request: method: GET - path: /v2/resources/registered-premises/records + path: /v2/resources/registered-premises/searches/within-bbox query: {bbox: "100,13,101,14", pageSize: 4, fields: "premisesIdentifier,premisesName"} expect: status: 200 @@ -156,7 +212,7 @@ steps: status: 200 recordIdentifier: PREM-SYNTH-0001 registryCoreRequired: true - domainDataKeys: [premisesIdentifier, businessRegistrationNumber, premisesName, location] + domainDataKeys: [premisesIdentifier, premisesName, location] - id: premises-read-jsonld request: method: GET @@ -165,61 +221,74 @@ steps: expect: status: 200 registryCoreRequired: true - domainDataKeys: [premisesIdentifier, businessRegistrationNumber, premisesName, location] + domainDataKeys: [premisesIdentifier, premisesName, location] recordsEquivalentTo: premises-read - id: premises-feature-collection request: method: GET - path: /v2/resources/registered-premises/records + path: /v2/resources/registered-premises/searches/within-bbox headers: {accept: application/geo+json} - query: {bbox: "100,13,101,14", profile: rfc7946} + query: {bbox: "100,13,101,14", formatProfile: rfc7946} expect: status: 200 itemCount: 2 nextCursor: non-null registryCoreRequired: true - domainDataKeys: [premisesIdentifier, businessRegistrationNumber, premisesName] + domainDataKeys: [premisesIdentifier, premisesName] geoJsonRoot: feature-collection geometryType: Point - representationProfile: rfc7946 + formatProfile: rfc7946 + recordsEquivalentTo: premises-first-page + - id: premises-search-jsonld + request: + method: GET + path: /v2/resources/registered-premises/searches/within-bbox + headers: {accept: application/ld+json} + query: {bbox: "100,13,101,14", pageSize: 2} + expect: + status: 200 + itemCount: 2 + nextCursor: non-null + registryCoreRequired: true + domainDataKeys: [premisesIdentifier, premisesName, location] recordsEquivalentTo: premises-first-page - id: premises-feature-collection-jsonfg request: method: GET - path: /v2/resources/registered-premises/records + path: /v2/resources/registered-premises/searches/within-bbox headers: {accept: application/geo+json} - query: {bbox: "100,13,101,14", profile: jsonfg} + query: {bbox: "100,13,101,14", formatProfile: jsonfg} expect: status: 200 itemCount: 2 nextCursor: non-null registryCoreRequired: true - domainDataKeys: [premisesIdentifier, businessRegistrationNumber, premisesName] + domainDataKeys: [premisesIdentifier, premisesName] geoJsonRoot: feature-collection geometryType: Point - representationProfile: jsonfg + formatProfile: jsonfg recordsEquivalentTo: premises-first-page - id: premises-feature-read request: method: GET path: /v2/resources/registered-premises/records/PREM-SYNTH-0001 headers: {accept: application/geo+json} - query: {profile: rfc7946} + query: {formatProfile: rfc7946} expect: status: 200 recordIdentifier: PREM-SYNTH-0001 registryCoreRequired: true - domainDataKeys: [premisesIdentifier, businessRegistrationNumber, premisesName] + domainDataKeys: [premisesIdentifier, premisesName] geoJsonRoot: feature geometryType: Point - representationProfile: rfc7946 + formatProfile: rfc7946 recordsEquivalentTo: premises-read - id: premises-feature-fields-omit-location request: method: GET - path: /v2/resources/registered-premises/records + path: /v2/resources/registered-premises/searches/within-bbox headers: {accept: application/geo+json} - query: {bbox: "100,13,101,14", pageSize: 4, fields: "premisesIdentifier,premisesName", profile: rfc7946} + query: {bbox: "100,13,101,14", pageSize: 4, fields: "premisesIdentifier,premisesName", formatProfile: rfc7946} expect: status: 200 itemCount: 3 @@ -228,18 +297,21 @@ steps: domainDataKeys: [premisesIdentifier, premisesName] geoJsonRoot: feature-collection geometryType: "null" - representationProfile: rfc7946 + formatProfile: rfc7946 recordsEquivalentTo: premises-fields-omit-location - id: premises-invalid-bbox request: method: GET - path: /v2/resources/registered-premises/records + path: /v2/resources/registered-premises/searches/within-bbox query: {bbox: "not,a,bbox"} expect: {status: 400, code: filter.invalid_value} + - id: premises-missing-bbox + request: {method: GET, path: /v2/resources/registered-premises/searches/within-bbox} + expect: {status: 400, code: filter.invalid_value} - id: premises-out-of-range-bbox request: method: GET - path: /v2/resources/registered-premises/records + path: /v2/resources/registered-premises/searches/within-bbox query: {bbox: "-181,13,101,14"} expect: status: 400 @@ -248,34 +320,48 @@ steps: - id: premises-oversize-bbox request: method: GET - path: /v2/resources/registered-premises/records + path: /v2/resources/registered-premises/searches/within-bbox query: {bbox: "100,13,103,14"} expect: {status: 400, code: filter.invalid_value} - id: premises-antimeridian-bbox request: method: GET - path: /v2/resources/registered-premises/records + path: /v2/resources/registered-premises/searches/within-bbox query: {bbox: "101,13,100,14"} expect: {status: 400, code: filter.invalid_value} - id: premises-cursor-bbox-binding request: method: GET - path: /v2/resources/registered-premises/records + path: /v2/resources/registered-premises/searches/within-bbox query: {cursor: "$nextCursor:premises-first-page", bbox: "100,13,101,14"} expect: {status: 400, code: query.cursor_invalid} - - id: premises-cursor-profile-binding + - id: premises-cursor-format-binding request: method: GET - path: /v2/resources/registered-premises/records + path: /v2/resources/registered-premises/searches/within-bbox headers: {accept: application/geo+json} query: {cursor: "$nextCursor:premises-first-page"} expect: {status: 400, code: query.cursor_invalid} + - id: premises-cursor-access-profile-binding + authorizationFixture: premises-search-registrar + request: + method: GET + path: /v2/resources/registered-premises/searches/within-bbox + query: {cursor: "$nextCursor:premises-first-page", accessProfile: registrar-premises} + expect: {status: 400, code: query.cursor_invalid} + - id: premises-cursor-operation-binding + authorizationFixture: premises-list + request: + method: GET + path: /v2/resources/registered-premises/records + query: {cursor: "$nextCursor:premises-first-page"} + expect: {status: 400, code: query.cursor_invalid} - id: nonspatial-geojson-refused request: method: GET path: /v2/resources/registered-business/records/BIZ-SYNTH-0001 headers: {accept: application/geo+json} - expect: {status: 406, code: representation.unsupported} + expect: {status: 406, code: format.unsupported} - id: invalid-coordinate-row request: {method: GET, path: /v2/resources/registered-premises/records/PREM-SYNTH-BAD1} expect: diff --git a/products/relay-v2/acceptance/business-registry/governance/classification-review-rationale.md b/products/relay-v2/acceptance/business-registry/governance/classification-review-rationale.md index 39e9798dd..0607b1801 100644 --- a/products/relay-v2/acceptance/business-registry/governance/classification-review-rationale.md +++ b/products/relay-v2/acceptance/business-registry/governance/classification-review-rationale.md @@ -1,5 +1,5 @@ # Classification review rationale -The public representation uses the reviewed pre-derived public view column. +The public access profile uses the reviewed pre-derived public view column. Registrar-only properties retain confidential handling and cannot appear in public metadata or cacheable responses. diff --git a/products/relay-v2/acceptance/business-registry/governance/classification-review.yaml b/products/relay-v2/acceptance/business-registry/governance/classification-review.yaml index 27d1ca608..610884d54 100644 --- a/products/relay-v2/acceptance/business-registry/governance/classification-review.yaml +++ b/products/relay-v2/acceptance/business-registry/governance/classification-review.yaml @@ -1,7 +1,7 @@ apiVersion: relay.registrystack.org/classification-review/v1 kind: ClassificationReview registryIdentifier: urn:example:registry:registered-businesses -classificationInventoryDigest: sha256:29522c4490bb26724b21e26bdd8fc8ebea9df0df12d75d23dc0b148eb62c0c40 +classificationInventoryDigest: sha256:efa130457a49b6cde79bdbc8ca7b10eb3b7d5dc77a8ca0f1e216e3eb89ed189e method: imported reviewer: urn:example:institution:company-registrar reviewDate: 2026-08-10 diff --git a/products/relay-v2/acceptance/business-registry/registry.yaml b/products/relay-v2/acceptance/business-registry/registry.yaml index 8690d349d..963c804fd 100644 --- a/products/relay-v2/acceptance/business-registry/registry.yaml +++ b/products/relay-v2/acceptance/business-registry/registry.yaml @@ -126,8 +126,8 @@ resources: properties: [registrationNumber, registrarLegalName, registrarNote, registrationStatus, legalForm, registeredJurisdiction] operations: list: - defaultRepresentation: public-register - representations: + defaultAccessProfile: public-register + accessProfiles: public-register: {access: public, disclosureProfile: public-register} registrar: access: {scope: registry:business:list-registrar} @@ -139,8 +139,8 @@ resources: orderBy: [registrationNumber] pagination: {defaultPageSize: 2, maximumPageSize: 4} read: - defaultRepresentation: public-register - representations: + defaultAccessProfile: public-register + accessProfiles: public-register: {access: public, disclosureProfile: public-register} registrar: access: {scope: registry:business:read-registrar} @@ -199,24 +199,43 @@ resources: description: Published name of the synthetic registered premises. disclosureProfiles: public-premises: + properties: [premisesIdentifier, premisesName, location] + registrar-premises: properties: [premisesIdentifier, businessRegistrationNumber, premisesName, location] operations: list: - defaultRepresentation: public-premises - representations: - public-premises: {access: public, disclosureProfile: public-premises} - allowUnfiltered: false - spatialQuery: - bbox: {maximumLongitudeSpanDegrees: 2, maximumLatitudeSpanDegrees: 2} + defaultAccessProfile: registrar-premises + accessProfiles: + registrar-premises: + access: {scope: registry:business:premises-list} + disclosureProfile: registrar-premises + allowUnfiltered: true orderBy: [premisesIdentifier] pagination: {defaultPageSize: 2, maximumPageSize: 4} read: - defaultRepresentation: public-premises - representations: + defaultAccessProfile: public-premises + accessProfiles: public-premises: {access: public, disclosureProfile: public-premises} + registrar-premises: + access: {scope: registry:business:premises-read-registrar} + disclosureProfile: registrar-premises + searches: + - id: within-bbox + query: + kind: point-bbox + maximumLongitudeSpanDegrees: 2 + maximumLatitudeSpanDegrees: 2 + defaultAccessProfile: public-premises + accessProfiles: + public-premises: {access: public, disclosureProfile: public-premises} + registrar-premises: + access: {scope: registry:business:premises-search-registrar} + disclosureProfile: registrar-premises + orderBy: [premisesIdentifier] + pagination: {defaultPageSize: 2, maximumPageSize: 4} processingDescriptions: - id: public-premises-publication - operationRefs: [list, read] + operationRefs: [list, read, search:within-bbox] purpose: statutory-publication recipientClass: public legalBasisRef: governance/legal-basis.yaml diff --git a/products/relay-v2/acceptance/civil-event/expected-http.yaml b/products/relay-v2/acceptance/civil-event/expected-http.yaml index 2170cf7d7..e9c4a6d0f 100644 --- a/products/relay-v2/acceptance/civil-event/expected-http.yaml +++ b/products/relay-v2/acceptance/civil-event/expected-http.yaml @@ -81,7 +81,7 @@ steps: request: method: POST path: /v2/resources/civil-event/lookups/verify-registration - query: {representation: supervisory, fields: "eventType,registrationYear,registrationYearMonth"} + query: {accessProfile: supervisory, fields: "eventType,registrationYear,registrationYearMonth"} body: {registrationNumber: REG-SYNTH-000001, eventType: BIRTH} expect: status: 200 @@ -89,20 +89,20 @@ steps: domainDataKeys: [eventType, registrationYear, registrationYearMonth] domainDataValues: {registrationYear: "2026", registrationYearMonth: "2026-04"} cache: no-store - - id: supervisory-representation-denied + - id: supervisory-access-profile-denied authorizationFixture: civil-verifier-ex-a request: method: POST path: /v2/resources/civil-event/lookups/verify-registration - query: {representation: supervisory} + query: {accessProfile: supervisory} body: {registrationNumber: REG-SYNTH-000001, eventType: BIRTH} expect: {status: 404, code: resource.not_found} - - id: invalid-representation + - id: invalid-access-profile authorizationFixture: civil-verifier-ex-a request: method: POST path: /v2/resources/civil-event/lookups/verify-registration - query: {representation: invalid} + query: {accessProfile: invalid} body: {registrationNumber: REG-SYNTH-000001, eventType: BIRTH} expect: {status: 404, code: resource.not_found} - id: no-list @@ -199,7 +199,7 @@ steps: request: method: POST path: /v2/resources/civil-event/lookups/verify-registration - query: {representation: supervisory} + query: {accessProfile: supervisory} body: {registrationNumber: REG-SYNTH-XFORM1, eventType: BIRTH} expect: {status: 503, code: source.unavailable} - id: quota-exhausted diff --git a/products/relay-v2/acceptance/civil-event/governance/classification-review-rationale.md b/products/relay-v2/acceptance/civil-event/governance/classification-review-rationale.md index 5e8082f71..e062979ee 100644 --- a/products/relay-v2/acceptance/civil-event/governance/classification-review-rationale.md +++ b/products/relay-v2/acceptance/civil-event/governance/classification-review-rationale.md @@ -2,4 +2,4 @@ The supervisory year-precision and year-month-precision properties are distinct reviewed outputs with separate public types and semantic terms. Exact -lookup remains protected and no collection representation is authorized. +lookup remains protected and no collection access profile is authorized. diff --git a/products/relay-v2/acceptance/civil-event/governance/classification-review.yaml b/products/relay-v2/acceptance/civil-event/governance/classification-review.yaml index e88663510..15ae51158 100644 --- a/products/relay-v2/acceptance/civil-event/governance/classification-review.yaml +++ b/products/relay-v2/acceptance/civil-event/governance/classification-review.yaml @@ -1,7 +1,7 @@ apiVersion: relay.registrystack.org/classification-review/v1 kind: ClassificationReview registryIdentifier: urn:example:registry:civil-events -classificationInventoryDigest: sha256:3da693b473e4989c6993fdd80ab9d312d650b6358335d20a32ea0de0836c10b7 +classificationInventoryDigest: sha256:2ddf244cfa195c322070d0154cf66a48618e8d5ea76cf931a9ad2c8c4682fc42 method: manual reviewer: urn:example:institution:civil-registration-authority reviewDate: 2026-08-10 diff --git a/products/relay-v2/acceptance/civil-event/registry.yaml b/products/relay-v2/acceptance/civil-event/registry.yaml index c7807d517..7db92d90a 100644 --- a/products/relay-v2/acceptance/civil-event/registry.yaml +++ b/products/relay-v2/acceptance/civil-event/registry.yaml @@ -139,8 +139,8 @@ resources: properties: [eventReference, eventType, registrationStatus, registrationYear, registrationYearMonth, certificateAvailable] operations: read: - defaultRepresentation: registrar - representations: + defaultAccessProfile: registrar + accessProfiles: registrar: access: scope: registry:civil-events:read @@ -154,8 +154,8 @@ resources: selectors: registrationNumber: {sourceColumn: registration_number, type: string, minimumBytes: 12, maximumBytes: 96} eventType: {sourceColumn: event_type, type: controlled-code, codelist: codelists/civil-event-selector-types.yaml} - defaultRepresentation: registrar-verification - representations: + defaultAccessProfile: registrar-verification + accessProfiles: registrar-verification: access: scope: registry:civil-events:lookup diff --git a/products/relay-v2/acceptance/social-assistance/expected-http.yaml b/products/relay-v2/acceptance/social-assistance/expected-http.yaml index ac490634d..40479d055 100644 --- a/products/relay-v2/acceptance/social-assistance/expected-http.yaml +++ b/products/relay-v2/acceptance/social-assistance/expected-http.yaml @@ -56,41 +56,41 @@ steps: registryCoreRequired: true domainDataKeys: [maskedEnrolmentReference, enrolmentStatus, validThrough] domainDataValues: {maskedEnrolmentReference: "***0001"} - - id: caseworker-representation + - id: caseworker-access-profile authorizationFixture: social-caseworker-area-a request: method: POST path: /v2/resources/assistance-enrolment/lookups/by-case-and-person - query: {representation: caseworker, fields: "enrolmentReference,programmeCode"} + query: {accessProfile: caseworker, fields: "enrolmentReference,programmeCode"} body: {caseReference: CASE-SYNTH-0001, personReference: PERSON-SYNTH-0001} expect: status: 200 registryCoreRequired: true domainDataKeys: [enrolmentReference, programmeCode] - - id: unauthorized-representation + - id: unauthorized-access-profile authorizationFixture: social-caseworker-wrong-scope request: method: POST path: /v2/resources/assistance-enrolment/lookups/by-case-and-person - query: {representation: caseworker} + query: {accessProfile: caseworker} body: {caseReference: CASE-SYNTH-0001, personReference: PERSON-SYNTH-0001} expect: {status: 404, code: resource.not_found} - - id: unknown-representation + - id: unknown-access-profile authorizationFixture: social-lookup-area-a request: method: POST path: /v2/resources/assistance-enrolment/lookups/by-case-and-person - query: {representation: unknown-profile} + query: {accessProfile: unknown-profile} body: {caseReference: CASE-SYNTH-0001, personReference: PERSON-SYNTH-0001} expect: {status: 404, code: resource.not_found} - - id: duplicate-representation + - id: duplicate-access-profile authorizationFixture: social-lookup-area-a request: method: POST path: /v2/resources/assistance-enrolment/lookups/by-case-and-person - query: {representation: "limited,caseworker"} + query: {accessProfile: "limited,caseworker"} body: {caseReference: CASE-SYNTH-0001, personReference: PERSON-SYNTH-0001} - expect: {status: 400, code: request.representation_invalid} + expect: {status: 400, code: request.access_profile_invalid} - id: lookup-second-subset authorizationFixture: social-lookup-area-a request: diff --git a/products/relay-v2/acceptance/social-assistance/governance/classification-review-rationale.md b/products/relay-v2/acceptance/social-assistance/governance/classification-review-rationale.md index b7d096ef8..3bcfa5b8e 100644 --- a/products/relay-v2/acceptance/social-assistance/governance/classification-review-rationale.md +++ b/products/relay-v2/acceptance/social-assistance/governance/classification-review-rationale.md @@ -1,5 +1,5 @@ # Classification review rationale -The reviewed limited and caseworker representations are necessary for bounded +The reviewed limited and caseworker access profiles are necessary for bounded benefit-delivery consultation. The masked reference remains identifying and the raw reference, selectors, and service-area binding remain restricted. diff --git a/products/relay-v2/acceptance/social-assistance/governance/classification-review.yaml b/products/relay-v2/acceptance/social-assistance/governance/classification-review.yaml index d4b9af147..d7eb7dd3a 100644 --- a/products/relay-v2/acceptance/social-assistance/governance/classification-review.yaml +++ b/products/relay-v2/acceptance/social-assistance/governance/classification-review.yaml @@ -1,7 +1,7 @@ apiVersion: relay.registrystack.org/classification-review/v1 kind: ClassificationReview registryIdentifier: urn:example:registry:social-assistance-enrolments -classificationInventoryDigest: sha256:2fdc2a749325762d815001b7afa8f25acbb93b22f3e8ea96a16bc92cb9ff9b03 +classificationInventoryDigest: sha256:01b68daa7f9ed5f92d95dea53957b6970f8ce527ccfa1d43f777a9573a0e6294 method: generated reviewer: urn:example:institution:social-protection-authority reviewDate: 2026-08-10 diff --git a/products/relay-v2/acceptance/social-assistance/registry.yaml b/products/relay-v2/acceptance/social-assistance/registry.yaml index 414b9787d..288980102 100644 --- a/products/relay-v2/acceptance/social-assistance/registry.yaml +++ b/products/relay-v2/acceptance/social-assistance/registry.yaml @@ -115,8 +115,8 @@ resources: selectors: caseReference: {sourceColumn: case_reference, type: string, minimumBytes: 8, maximumBytes: 96} personReference: {sourceColumn: person_reference, type: string, minimumBytes: 8, maximumBytes: 96} - defaultRepresentation: limited - representations: + defaultAccessProfile: limited + accessProfiles: limited: access: scope: registry:social-assistance:limited diff --git a/products/relay-v2/contracts/acceptance-scenario-matrix.yaml b/products/relay-v2/contracts/acceptance-scenario-matrix.yaml index 44fa69c55..e5d1f5799 100644 --- a/products/relay-v2/contracts/acceptance-scenario-matrix.yaml +++ b/products/relay-v2/contracts/acceptance-scenario-matrix.yaml @@ -4,11 +4,11 @@ execution: products/relay-v2/scripts/test-http.sh scenarios: - {id: social-ready, project: social-assistance, journeyStep: readiness, assertion: One local protected Registry becomes ready from a generated package.} - {id: social-lookup, project: social-assistance, journeyStep: lookup-success, assertion: A bounded protected lookup returns only its selected governed properties.} - - {id: social-lookup-default, project: social-assistance, journeyStep: lookup-default, assertion: The default representation contains exactly the compiled disclosure profile and the exact safe partial-string result rather than the source identifier.} - - {id: social-caseworker-representation, project: social-assistance, journeyStep: caseworker-representation, assertion: An entitled caseworker explicitly selects its full representation then narrows fields within it.} - - {id: social-unauthorized-representation, project: social-assistance, journeyStep: unauthorized-representation, assertion: A caller without the selected representation scope receives the same concealed resource outcome without fallback to limited.} - - {id: social-unknown-representation, project: social-assistance, journeyStep: unknown-representation, assertion: An unknown representation receives the same concealed resource outcome as a scope-hidden representation.} - - {id: social-duplicate-representation, project: social-assistance, journeyStep: duplicate-representation, assertion: A malformed representation selection is rejected before source access.} + - {id: social-lookup-default, project: social-assistance, journeyStep: lookup-default, assertion: The default access profile contains exactly the compiled disclosure profile and the exact safe partial-string result rather than the source identifier.} + - {id: social-caseworker-access-profile, project: social-assistance, journeyStep: caseworker-access-profile, assertion: An entitled caseworker explicitly selects its full access profile then narrows fields within it.} + - {id: social-unauthorized-access-profile, project: social-assistance, journeyStep: unauthorized-access-profile, assertion: A caller without the selected access profile scope receives the same concealed resource outcome without fallback to limited.} + - {id: social-unknown-access-profile, project: social-assistance, journeyStep: unknown-access-profile, assertion: An unknown access profile receives the same concealed resource outcome as a scope-hidden profile.} + - {id: social-duplicate-access-profile, project: social-assistance, journeyStep: duplicate-access-profile, assertion: A malformed access profile selection is rejected before source access.} - {id: social-lookup-second-subset, project: social-assistance, journeyStep: lookup-second-subset, assertion: A second valid field subset remains governed independently.} - {id: social-lookup-jsonld, project: social-assistance, journeyStep: lookup-jsonld, assertion: JSON and JSON-LD encodings carry equivalent Registry records.} - {id: social-route, project: social-assistance, journeyStep: route-confinement, assertion: An undeclared collection route is absent.} @@ -36,9 +36,9 @@ scenarios: - {id: business-filter, project: business-registry, journeyStep: filtered-page, assertion: Declared exact filters and a selected field subset remain governed.} - {id: business-second-subset, project: business-registry, journeyStep: second-field-subset, assertion: A second valid field subset remains governed independently.} - {id: business-read, project: business-registry, journeyStep: identifier-read, assertion: An identifier read is cacheable under the snapshot posture.} - - {id: business-registrar-read, project: business-registry, journeyStep: registrar-read, assertion: The protected registrar representation is selected explicitly and remains no-store.} - - {id: business-registrar-denied, project: business-registry, journeyStep: registrar-representation-denied, assertion: A caller without the registrar representation scope receives the concealed resource outcome.} - - {id: business-public-unknown-representation, project: business-registry, journeyStep: public-representation-unknown, assertion: Public discovery cannot enumerate an unknown representation or turn it into a fallback.} + - {id: business-registrar-read, project: business-registry, journeyStep: registrar-read, assertion: The protected registrar access profile is selected explicitly and remains no-store.} + - {id: business-registrar-denied, project: business-registry, journeyStep: registrar-access-profile-denied, assertion: A caller without the registrar access profile scope receives the concealed resource outcome.} + - {id: business-public-unknown-access-profile, project: business-registry, journeyStep: public-access-profile-unknown, assertion: Public discovery cannot enumerate an unknown access profile or turn it into a fallback.} - {id: business-read-jsonld, project: business-registry, journeyStep: identifier-read-jsonld, assertion: JSON and JSON-LD encodings carry equivalent Registry records.} - {id: business-revalidation, project: business-registry, journeyStep: identifier-read-revalidated, assertion: A matching ETag produces an empty 304 response with the same validator.} - {id: business-unknown-field, project: business-registry, journeyStep: unknown-field, assertion: An unknown public property is rejected before source access.} @@ -50,32 +50,42 @@ scenarios: - {id: business-operator-rejection, project: business-registry, journeyStep: unsupported-filter-operator, assertion: Callers cannot add an operator to a declared direct filter.} - {id: business-invalid, project: business-registry, journeyStep: invalid-source-row, invalidSourceRowClass: wrong-type, expectedStatus: 503, expectedCode: source.unavailable, assertion: An invalid source row fails closed and is not released.} - {id: business-premises-first-page, project: business-registry, journeyStep: premises-first-page, assertion: A bounded public Point search is paginated and exposes only its governed geometry and properties.} + - {id: business-premises-registrar-search, project: business-registry, journeyStep: premises-registrar-search, assertion: The protected search access profile releases its additional business reference only to its exact scope.} + - {id: business-premises-search-access-denied, project: business-registry, journeyStep: premises-search-access-profile-denied, assertion: A caller without the selected search access-profile scope receives the concealed resource outcome.} + - {id: business-premises-search-access-unknown, project: business-registry, journeyStep: premises-search-access-profile-unknown, assertion: An unknown search access profile is indistinguishable from a scope-hidden profile.} + - {id: business-premises-list-authorized, project: business-registry, journeyStep: premises-list-authorized, assertion: A separately scoped registrar list remains a distinct compiled operation.} + - {id: business-premises-search-cannot-list, project: business-registry, journeyStep: premises-search-scope-cannot-list, assertion: A protected search scope cannot synthesize the separately governed list operation.} + - {id: business-premises-list-cannot-search, project: business-registry, journeyStep: premises-list-scope-cannot-search, assertion: A list scope cannot select the protected access profile of the named search.} - {id: business-premises-second-page, project: business-registry, journeyStep: premises-second-page, assertion: A Point-search cursor retrieves the remaining exact-bbox result page.} - {id: business-premises-boundary, project: business-registry, journeyStep: premises-boundary-point, assertion: Exact Point bbox containment includes a point on every bounding edge.} - - {id: business-premises-minimization, project: business-registry, journeyStep: premises-fields-omit-location, assertion: A requester can omit the classified geometry without changing the governed representation.} + - {id: business-premises-minimization, project: business-registry, journeyStep: premises-fields-omit-location, assertion: A requester can omit the classified geometry without changing the access profile.} - {id: business-premises-read, project: business-registry, journeyStep: premises-read, assertion: Identifier read returns the same governed Point field in the ordinary Registry Record.} - {id: business-premises-jsonld, project: business-registry, journeyStep: premises-read-jsonld, assertion: JSON-LD preserves the same governed Point data and Registry Core while applying the generated context.} - - {id: business-premises-geojson, project: business-registry, journeyStep: premises-feature-collection, assertion: The selected governed representation serializes as RFC 7946 GeoJSON.} + - {id: business-premises-geojson, project: business-registry, journeyStep: premises-feature-collection, assertion: The selected access profile serializes as RFC 7946 GeoJSON.} + - {id: business-premises-search-jsonld, project: business-registry, journeyStep: premises-search-jsonld, assertion: The named search serializes equivalent Records as JSON-LD without changing its access profile or query result.} - {id: business-premises-jsonfg, project: business-registry, journeyStep: premises-feature-collection-jsonfg, assertion: JSON-FG is an explicit response profile over the same governed Point data.} - {id: business-premises-feature-read, project: business-registry, journeyStep: premises-feature-read, assertion: Identifier read serializes one governed Record as a GeoJSON Feature.} - {id: business-premises-feature-minimization, project: business-registry, journeyStep: premises-feature-fields-omit-location, assertion: GeoJSON field minimization emits an explicit null geometry and does not reintroduce the omitted Point through Feature properties.} - {id: business-premises-invalid-bbox, project: business-registry, journeyStep: premises-invalid-bbox, assertion: Malformed Point bbox input fails before source access.} + - {id: business-premises-missing-bbox, project: business-registry, journeyStep: premises-missing-bbox, assertion: The named point-bbox search requires its complete query shape before source access.} - {id: business-premises-out-of-range-bbox, project: business-registry, journeyStep: premises-out-of-range-bbox, assertion: A CRS84 coordinate outside the world range is refused without echoing its value.} - {id: business-premises-oversize-bbox, project: business-registry, journeyStep: premises-oversize-bbox, assertion: A bbox outside the publisher span ceiling is refused.} - {id: business-premises-antimeridian, project: business-registry, journeyStep: premises-antimeridian-bbox, assertion: The initial Point profile refuses antimeridian-crossing bbox input.} - {id: business-premises-cursor-bbox, project: business-registry, journeyStep: premises-cursor-bbox-binding, assertion: A cursor cannot be combined with a first-page bbox parameter.} - - {id: business-premises-cursor-profile, project: business-registry, journeyStep: premises-cursor-profile-binding, assertion: A cursor cannot cross its negotiated response format or GeoJSON profile.} - - {id: business-nonspatial-geojson, project: business-registry, journeyStep: nonspatial-geojson-refused, assertion: GeoJSON is unavailable when the selected governed representation does not disclose a primary geometry.} + - {id: business-premises-cursor-format, project: business-registry, journeyStep: premises-cursor-format-binding, assertion: A cursor cannot cross its negotiated wire format or GeoJSON format profile.} + - {id: business-premises-cursor-access-profile, project: business-registry, journeyStep: premises-cursor-access-profile-binding, assertion: A cursor cannot cross its selected access profile even when the caller is entitled to both profiles.} + - {id: business-premises-cursor-operation, project: business-registry, journeyStep: premises-cursor-operation-binding, assertion: A named-search cursor cannot be replayed against the separately governed list operation.} + - {id: business-nonspatial-geojson, project: business-registry, journeyStep: nonspatial-geojson-refused, assertion: GeoJSON is unavailable when the selected access profile does not disclose a primary geometry.} - {id: business-premises-invalid-coordinate, project: business-registry, journeyStep: invalid-coordinate-row, invalidSourceRowClass: wrong-type, expectedStatus: 503, expectedCode: source.unavailable, assertion: An unsafe coordinate row fails closed without a value-bearing diagnostic.} - {id: civil-discovery, project: civil-event, journeyStep: registry-discovery, assertion: Discovery derives retrieve and constrained search only.} - - {id: civil-read, project: civil-event, journeyStep: registrar-read, assertion: The registrar scope receives its narrower requested representation.} - - {id: civil-read-default, project: civil-event, journeyStep: registrar-read-default, assertion: The default representation contains exactly the compiled disclosure profile.} + - {id: civil-read, project: civil-event, journeyStep: registrar-read, assertion: The registrar scope receives its narrower requested access profile.} + - {id: civil-read-default, project: civil-event, journeyStep: registrar-read-default, assertion: The default access profile contains exactly the compiled disclosure profile.} - {id: civil-read-second-subset, project: civil-event, journeyStep: registrar-read-second-subset, assertion: A second valid field subset remains governed independently.} - {id: civil-read-jsonld, project: civil-event, journeyStep: registrar-read-jsonld, assertion: JSON and JSON-LD encodings carry equivalent Registry records.} - {id: civil-lookup, project: civil-event, journeyStep: lookup-success, assertion: The verification scope receives its distinct disclosure profile.} - - {id: civil-supervisory-date-precision, project: civil-event, journeyStep: supervisory-date-precision, assertion: The supervisory representation returns exact reviewed year and year-month outputs with distinct public types and semantic terms over the same exact lookup.} - - {id: civil-supervisory-denied, project: civil-event, journeyStep: supervisory-representation-denied, assertion: A registrar-verification grant receives the concealed resource outcome and cannot fall back from a supervisory representation.} - - {id: civil-invalid-representation, project: civil-event, journeyStep: invalid-representation, assertion: An unknown civil representation receives the concealed resource outcome before lookup execution.} + - {id: civil-supervisory-date-precision, project: civil-event, journeyStep: supervisory-date-precision, assertion: The supervisory access profile returns exact reviewed year and year-month outputs with distinct public types and semantic terms over the same exact lookup.} + - {id: civil-supervisory-denied, project: civil-event, journeyStep: supervisory-access-profile-denied, assertion: A registrar-verification grant receives the concealed resource outcome and cannot fall back from a supervisory access profile.} + - {id: civil-invalid-access-profile, project: civil-event, journeyStep: invalid-access-profile, assertion: An unknown civil access profile receives the concealed resource outcome before lookup execution.} - {id: civil-route, project: civil-event, journeyStep: no-list, assertion: A non-enumerable Registry has no collection route.} - {id: civil-scope, project: civil-event, journeyStep: scope-separation, assertion: A lookup scope cannot synthesize identifier read.} - {id: civil-reverse-scope, project: civil-event, journeyStep: reverse-scope-separation, assertion: A registrar read scope cannot synthesize verification lookup.} diff --git a/products/relay-v2/contracts/artifact-inventory.yaml b/products/relay-v2/contracts/artifact-inventory.yaml index 64f589a46..038cde1ca 100644 --- a/products/relay-v2/contracts/artifact-inventory.yaml +++ b/products/relay-v2/contracts/artifact-inventory.yaml @@ -11,30 +11,30 @@ artifacts: visibility: public source: compiled-registry generated: true - - id: representation-schema + - id: access-profile-schema mediaType: application/schema+json visibility: operation-compatible source: compiled-resource generated: true - invariant: One artifact exists per compiled operation representation and validates mandatory Registry Core with every allowed selected-profile domainData subset. + invariant: One artifact exists per compiled operation access profile and validates mandatory Registry Core with every allowed selected-profile domainData subset. - id: geojson-response-schema mediaType: application/schema+json visibility: operation-compatible - source: compiled-operation-representation + source: compiled-operation-access-profile generated: true - invariant: Exists only when a selected governed representation discloses the resource primary Point geometry and validates its RFC 7946 or JSON-FG response shape. - - id: representation-shacl + invariant: Exists only when a selected access profile discloses the resource primary Point geometry and validates its RFC 7946 or JSON-FG response shape. + - id: access-profile-shacl mediaType: text/turtle visibility: operation-compatible - source: compiled-operation-representation + source: compiled-operation-access-profile generated: true - invariant: One shape exists per compiled operation representation; public projection never inventories protected profile identifiers. + invariant: One shape exists per compiled operation access profile; public projection never inventories protected profile identifiers. - id: full-record-schema mediaType: application/schema+json visibility: operator-only source: compiled-resource generated: true - invariant: Validates the complete reviewed source representation before field minimization. + invariant: Validates the complete reviewed source Record before field minimization. - id: full-record-shacl mediaType: text/turtle visibility: operator-only @@ -72,6 +72,7 @@ artifacts: visibility: operator-only source: relay-audit-vocabulary generated: true + invariant: The v2alpha1 audit vocabulary records the categorical access profile and never selector, bbox, principal, or source values. - id: identification-report mediaType: application/json visibility: operator-only @@ -84,12 +85,12 @@ artifacts: source: compiled-classifications generated: true invariant: Accounts for every processed source column and disclosed property. - - id: representation-report + - id: operation-explanation mediaType: application/json visibility: operator-only - source: compiled-operation-representations + source: compiled-operations generated: true - invariant: Separates per-representation processed columns and processing handling from disclosed properties and disclosure handling. + invariant: Explains each operation's path, query capability, access profiles, disclosure, transforms, and wire formats without source or authorization-claim values. - id: contextual-review-findings mediaType: application/json visibility: operator-only diff --git a/products/relay-v2/contracts/generated-baselines.yaml b/products/relay-v2/contracts/generated-baselines.yaml index a55a2797c..447de6ae7 100644 --- a/products/relay-v2/contracts/generated-baselines.yaml +++ b/products/relay-v2/contracts/generated-baselines.yaml @@ -2,198 +2,198 @@ schemaVersion: relay.registrystack.org/generated-baselines/v1alpha1 product: relay-v2 projects: social-assistance: - packageRevision: sha256:8e2f70e492bbfbbf626b18d9998c03b0588155bc3419c61fdecabc88a263ae46 - contractRevision: sha256:0fd06f53b937afbb0252715010ff222c4cb8817a6c62648a2a72ac4d35eae282 + packageRevision: sha256:63217db19dcc9c53240a60137bba4c88b00e880c86e7a3a6f4dd2c7cb798cf58 + contractRevision: sha256:9011885e752b26128bf6c98798e5fd674624ae04912cd37c7597311e8a805b1e sourceSchemaFingerprints: assistance: sha256:936a90a03d06be67a76226d6999a830c04f6604a3ff8b340a62fdd378d8c6d91 artifacts: - - id: assistance-enrolment--lookup-by-case-and-person--representation-caseworker-capability + - accessProfileIdentifier: caseworker + id: assistance-enrolment--lookup-by-case-and-person--access-profile-caseworker-capability mediaType: application/json operationIdentifier: assistance-enrolment.lookup.by-case-and-person - path: generated/artifacts/assistance-enrolment--lookup-by-case-and-person--representation-caseworker.capability.json - representationIdentifier: caseworker - sha256: sha256:a9993929a908172d36ac50e787f3c4a87a39ba64cb8e677c19d9ff000083dca0 + path: generated/artifacts/assistance-enrolment--lookup-by-case-and-person--access-profile-caseworker.capability.json + sha256: sha256:ffde4588cbf935c6ee2f2803c1f4a912e6621dac057b60fbf46236bc67e3640b visibility: operation-bound - - id: assistance-enrolment--lookup-by-case-and-person--representation-caseworker-classifications + - accessProfileIdentifier: null + id: assistance-enrolment--lookup-by-case-and-person--access-profile-caseworker-classifications mediaType: application/json operationIdentifier: null - path: generated/artifacts/assistance-enrolment--lookup-by-case-and-person--representation-caseworker.classifications.json - representationIdentifier: null - sha256: sha256:d43a82489dd4be38d6176baf09aea8529e2c3d352a819269460305741b590dc2 + path: generated/artifacts/assistance-enrolment--lookup-by-case-and-person--access-profile-caseworker.classifications.json + sha256: sha256:57a918902073fdd17ca48974e6e7d153eed11a9f33409b39e193f6c5030f53f8 visibility: operator-only - - id: assistance-enrolment--lookup-by-case-and-person--representation-caseworker-context + - accessProfileIdentifier: caseworker + id: assistance-enrolment--lookup-by-case-and-person--access-profile-caseworker-context mediaType: application/ld+json operationIdentifier: assistance-enrolment.lookup.by-case-and-person - path: generated/artifacts/assistance-enrolment--lookup-by-case-and-person--representation-caseworker.context.jsonld - representationIdentifier: caseworker + path: generated/artifacts/assistance-enrolment--lookup-by-case-and-person--access-profile-caseworker.context.jsonld sha256: sha256:220f8ac8890bd1167e90c4aa836d75858d89cc0016173227ac7aafaf6a8e9b07 visibility: operation-bound - - id: assistance-enrolment--lookup-by-case-and-person--representation-caseworker-processing + - accessProfileIdentifier: caseworker + id: assistance-enrolment--lookup-by-case-and-person--access-profile-caseworker-processing mediaType: application/json operationIdentifier: assistance-enrolment.lookup.by-case-and-person - path: generated/artifacts/assistance-enrolment--lookup-by-case-and-person--representation-caseworker.processing.json - representationIdentifier: caseworker - sha256: sha256:09caa79d0afbfc1f133b7d9c551d7b595d0875cbfd1c5dd956b5c1e3a7063216 + path: generated/artifacts/assistance-enrolment--lookup-by-case-and-person--access-profile-caseworker.processing.json + sha256: sha256:af17652b596290134bb38c594b14f7dac2b9caa6b9bef1119511b2f399053e3e visibility: operation-bound - - id: assistance-enrolment--lookup-by-case-and-person--representation-caseworker-schema + - accessProfileIdentifier: caseworker + id: assistance-enrolment--lookup-by-case-and-person--access-profile-caseworker-schema mediaType: application/schema+json operationIdentifier: assistance-enrolment.lookup.by-case-and-person - path: generated/artifacts/assistance-enrolment--lookup-by-case-and-person--representation-caseworker.schema.json - representationIdentifier: caseworker - sha256: sha256:c16f484f5255903f91f86fac25a81d5e7cbcf07ada0a5da1fa3b97c2c5b649cd + path: generated/artifacts/assistance-enrolment--lookup-by-case-and-person--access-profile-caseworker.schema.json + sha256: sha256:447d9fcb5e36478b1b809d55489c07157deaf033ca15d3363163bccd993fdf5f visibility: operation-bound - - id: assistance-enrolment--lookup-by-case-and-person--representation-caseworker-shacl + - accessProfileIdentifier: caseworker + id: assistance-enrolment--lookup-by-case-and-person--access-profile-caseworker-shacl mediaType: text/turtle operationIdentifier: assistance-enrolment.lookup.by-case-and-person - path: generated/artifacts/assistance-enrolment--lookup-by-case-and-person--representation-caseworker.shacl.ttl - representationIdentifier: caseworker + path: generated/artifacts/assistance-enrolment--lookup-by-case-and-person--access-profile-caseworker.shacl.ttl sha256: sha256:68664354ffccbe112d1a7e06dd68d96d5cd66bc681bc8e44495875b46696c076 visibility: operation-bound - - id: assistance-enrolment--lookup-by-case-and-person--representation-caseworker-vocabulary + - accessProfileIdentifier: caseworker + id: assistance-enrolment--lookup-by-case-and-person--access-profile-caseworker-vocabulary mediaType: application/ld+json operationIdentifier: assistance-enrolment.lookup.by-case-and-person - path: generated/artifacts/assistance-enrolment--lookup-by-case-and-person--representation-caseworker.vocabulary.jsonld - representationIdentifier: caseworker + path: generated/artifacts/assistance-enrolment--lookup-by-case-and-person--access-profile-caseworker.vocabulary.jsonld sha256: sha256:d894822eb794725509df894466e19f0e96caa99e8a612a358cb1dc25b71a1a86 visibility: operation-bound - - id: assistance-enrolment--lookup-by-case-and-person--representation-limited-capability + - accessProfileIdentifier: limited + id: assistance-enrolment--lookup-by-case-and-person--access-profile-limited-capability mediaType: application/json operationIdentifier: assistance-enrolment.lookup.by-case-and-person - path: generated/artifacts/assistance-enrolment--lookup-by-case-and-person--representation-limited.capability.json - representationIdentifier: limited - sha256: sha256:7aa208acd66c410516ffaade76c2be7b71e932bc774e39917e3aadc8d142a2d6 + path: generated/artifacts/assistance-enrolment--lookup-by-case-and-person--access-profile-limited.capability.json + sha256: sha256:f12ff40c01a8edca83840cf6e865bb9b5a133cec160fed89ec68a875955bbea8 visibility: operation-bound - - id: assistance-enrolment--lookup-by-case-and-person--representation-limited-classifications + - accessProfileIdentifier: null + id: assistance-enrolment--lookup-by-case-and-person--access-profile-limited-classifications mediaType: application/json operationIdentifier: null - path: generated/artifacts/assistance-enrolment--lookup-by-case-and-person--representation-limited.classifications.json - representationIdentifier: null - sha256: sha256:dbce084d1bc2bef012da77f20b3e88276308047810b881961c95dfa41c1ba0e7 + path: generated/artifacts/assistance-enrolment--lookup-by-case-and-person--access-profile-limited.classifications.json + sha256: sha256:cab6d216d1ce7e68ff149eb73971ad317977dbd1108c2b4b7e00e00b1f5db4de visibility: operator-only - - id: assistance-enrolment--lookup-by-case-and-person--representation-limited-context + - accessProfileIdentifier: limited + id: assistance-enrolment--lookup-by-case-and-person--access-profile-limited-context mediaType: application/ld+json operationIdentifier: assistance-enrolment.lookup.by-case-and-person - path: generated/artifacts/assistance-enrolment--lookup-by-case-and-person--representation-limited.context.jsonld - representationIdentifier: limited + path: generated/artifacts/assistance-enrolment--lookup-by-case-and-person--access-profile-limited.context.jsonld sha256: sha256:58aec134af96c8b39f9f0a8dde7c5dd780fedc419637fe1848724a4120d54fc9 visibility: operation-bound - - id: assistance-enrolment--lookup-by-case-and-person--representation-limited-processing + - accessProfileIdentifier: limited + id: assistance-enrolment--lookup-by-case-and-person--access-profile-limited-processing mediaType: application/json operationIdentifier: assistance-enrolment.lookup.by-case-and-person - path: generated/artifacts/assistance-enrolment--lookup-by-case-and-person--representation-limited.processing.json - representationIdentifier: limited - sha256: sha256:b22930dda04c8043d787bf6979092a89baba41c1772eb26a294523b48b93953e + path: generated/artifacts/assistance-enrolment--lookup-by-case-and-person--access-profile-limited.processing.json + sha256: sha256:3b16cd7620010e103eb2da975a9036d89ffc0d68ffc23b26374a4b540e17bdd0 visibility: operation-bound - - id: assistance-enrolment--lookup-by-case-and-person--representation-limited-schema + - accessProfileIdentifier: limited + id: assistance-enrolment--lookup-by-case-and-person--access-profile-limited-schema mediaType: application/schema+json operationIdentifier: assistance-enrolment.lookup.by-case-and-person - path: generated/artifacts/assistance-enrolment--lookup-by-case-and-person--representation-limited.schema.json - representationIdentifier: limited - sha256: sha256:728f6d4fdc24e33ccad2d36c17d7c403ed5927c93fbbc62578ed13b990c7c6a2 + path: generated/artifacts/assistance-enrolment--lookup-by-case-and-person--access-profile-limited.schema.json + sha256: sha256:36b2a2583c668b50241a5ec9ee787126bb83d53a7517f9c11e9aa1286e27c7d5 visibility: operation-bound - - id: assistance-enrolment--lookup-by-case-and-person--representation-limited-shacl + - accessProfileIdentifier: limited + id: assistance-enrolment--lookup-by-case-and-person--access-profile-limited-shacl mediaType: text/turtle operationIdentifier: assistance-enrolment.lookup.by-case-and-person - path: generated/artifacts/assistance-enrolment--lookup-by-case-and-person--representation-limited.shacl.ttl - representationIdentifier: limited + path: generated/artifacts/assistance-enrolment--lookup-by-case-and-person--access-profile-limited.shacl.ttl sha256: sha256:ea795cccdde860699ed7998e38cdae8f4dd1784acda936242a82cb62b796f95f visibility: operation-bound - - id: assistance-enrolment--lookup-by-case-and-person--representation-limited-vocabulary + - accessProfileIdentifier: limited + id: assistance-enrolment--lookup-by-case-and-person--access-profile-limited-vocabulary mediaType: application/ld+json operationIdentifier: assistance-enrolment.lookup.by-case-and-person - path: generated/artifacts/assistance-enrolment--lookup-by-case-and-person--representation-limited.vocabulary.jsonld - representationIdentifier: limited + path: generated/artifacts/assistance-enrolment--lookup-by-case-and-person--access-profile-limited.vocabulary.jsonld sha256: sha256:d856a45b101cce510ad7ff1d1773f0326e69cd4b52d5ea00fa6534f74d79a881 visibility: operation-bound - - id: assistance-enrolment-classification + - accessProfileIdentifier: null + id: assistance-enrolment-classification mediaType: application/json operationIdentifier: null path: generated/artifacts/assistance-enrolment.classifications.json - representationIdentifier: null sha256: sha256:32707dfb3d94080914c914d5bded55741758dfd4e1f2eef49c89e4376042eace visibility: operator-only - - id: assistance-enrolment-codelist-0 + - accessProfileIdentifier: null + id: assistance-enrolment-codelist-0 mediaType: application/schema+json operationIdentifier: null path: generated/artifacts/assistance-enrolment.codelist-0.schema.json - representationIdentifier: null sha256: sha256:a836883fac30ae1cacbd657d7429ff08f9bfff1a3b8abea0bcc4fa5401a7f200 visibility: operator-only - - id: assistance-enrolment-codelist-1 + - accessProfileIdentifier: null + id: assistance-enrolment-codelist-1 mediaType: application/schema+json operationIdentifier: null path: generated/artifacts/assistance-enrolment.codelist-1.schema.json - representationIdentifier: null sha256: sha256:4dd49c40c44f8acbd56f319d4af5c9b48ffee24e5b0bd267f0c6f4833adc73d1 visibility: operator-only - - id: assistance-enrolment-codelist-2 + - accessProfileIdentifier: null + id: assistance-enrolment-codelist-2 mediaType: application/schema+json operationIdentifier: null path: generated/artifacts/assistance-enrolment.codelist-2.schema.json - representationIdentifier: null sha256: sha256:e42dfbcab45a66032d126e0f203523ae44a6bc034278f2ce222f96f1ff0a78f0 visibility: operator-only - - id: assistance-enrolment-full-schema + - accessProfileIdentifier: null + id: assistance-enrolment-full-schema mediaType: application/schema+json operationIdentifier: null path: generated/artifacts/assistance-enrolment.full.schema.json - representationIdentifier: null sha256: sha256:4c669711db0b989ac58e1a36ec52afedd05fe2726f7439c0f204fcb0050bb79d visibility: operator-only - - id: assistance-enrolment-full-shacl + - accessProfileIdentifier: null + id: assistance-enrolment-full-shacl mediaType: text/turtle operationIdentifier: null path: generated/artifacts/assistance-enrolment.full.shacl.ttl - representationIdentifier: null sha256: sha256:53324cf42d1b66d8292897b7d046fe7a68f2c99802c3df7963bb17506ed9e1ad visibility: operator-only - - id: assistance-enrolment-full-vocabulary + - accessProfileIdentifier: null + id: assistance-enrolment-full-vocabulary mediaType: application/ld+json operationIdentifier: null path: generated/artifacts/assistance-enrolment.full.vocabulary.jsonld - representationIdentifier: null sha256: sha256:ce35a8374c9a8758f7eb42ed86f77503f2f371ba3da16d17eaa2df1d7a320f92 visibility: operator-only - - id: assistance-enrolment-processing-full + - accessProfileIdentifier: null + id: assistance-enrolment-processing-full mediaType: application/json operationIdentifier: null path: generated/artifacts/assistance-enrolment.processing.full.json - representationIdentifier: null sha256: sha256:b3806fac8892ef081c3d8e26ca475fb37c3d318302f593b25828c20110a1f7b5 visibility: operator-only - - id: audit-event-schema + - accessProfileIdentifier: null + id: audit-event-schema mediaType: application/schema+json operationIdentifier: null path: generated/artifacts/audit-event.schema.json - representationIdentifier: null - sha256: sha256:2b3223ef49813d9b1602317a363a98231978aab34f0b35403c5ef407b6499913 + sha256: sha256:2600120dbc7fbbb0f8d4feaa7cb811055b6f2590ad83c4472d5af982ae004a45 visibility: operator-only - - id: capability-inventory-full + - accessProfileIdentifier: null + id: capability-inventory-full mediaType: application/json operationIdentifier: null path: generated/artifacts/capabilities.full.json - representationIdentifier: null - sha256: sha256:0f87ce2269c666b3ecbbc48600d3f02c83c41e110eee58c86ce866326b5b9e05 + sha256: sha256:4270b8f73ae1f74aec83079fe8b514e3538c0a479b821c4c8c2feef83bd0ff2f visibility: operator-only - - id: capability-inventory + - accessProfileIdentifier: null + id: capability-inventory mediaType: application/json operationIdentifier: null path: generated/artifacts/capabilities.json - representationIdentifier: null - sha256: sha256:521fd460f8b4bde91b2f82299b15b3ae99cb9146a73ed3609df9a7a305d6b163 + sha256: sha256:a2ca8379d9e94604b07109db19087def16b380ce977bfc90a74c3bc92e795d85 visibility: public - - id: openapi-full + - accessProfileIdentifier: null + id: openapi-full mediaType: application/yaml operationIdentifier: null path: generated/openapi.full.yaml - representationIdentifier: null - sha256: sha256:ea709aa981e71094cfa8be399932d35935bac0a2c8fa04548ef2e1757045d777 + sha256: sha256:9902dae9ca28f926b9a646c027321be5bc147d2a668d910c2ab5a77d7ae04c4a visibility: operator-only - - id: openapi-public + - accessProfileIdentifier: null + id: openapi-public mediaType: application/json operationIdentifier: null path: generated/openapi.public.json - representationIdentifier: null sha256: sha256:b1a460e09d3d45200f82d9c5f44f5b7fdd2e04db4ee226e716b9104af4573ac8 visibility: public governedFiles: @@ -218,13 +218,13 @@ projects: - generated: false mediaType: application/yaml path: governed/governance/classification-review-rationale.md - sha256: sha256:0419a970e434e8e8228f42966ebe22ca8d74de52aa85491087d1d41a033c20a3 + sha256: sha256:377253745d4f0f85e1bbcb25ec470c93efafdf320ae01805bac798e78a8830f1 size: 263 visibility: operator-only - generated: false mediaType: application/yaml path: governed/governance/classification-review.yaml - sha256: sha256:7f6e34b231e0b6344a324bf33bf6ea9fed39508789212f0ecef09abea4bcea61 + sha256: sha256:7879a4065f12d81278d84fa5f18a9b33f87ae64101f3f920f34424f699a9c829 size: 763 visibility: operator-only - generated: false @@ -248,434 +248,602 @@ projects: - generated: false mediaType: application/yaml path: registry.yaml - sha256: sha256:522486c68a2e4a80798fe8c5291892c2e371391db4a4e0ccf1682ba1ea27b025 - size: 6472 + sha256: sha256:621a5951fd86471650e5b3e05da83c2686bb525fbe584d3fbf7b7193d3c651a7 + size: 6470 visibility: operator-only business-registry: - packageRevision: sha256:d66bbcae2a74ef5120c1f7f9f0577bc49be6b4da25ff209aa33463c0911b4da1 - contractRevision: sha256:6edba24daf8fd0550a3b2e6ee163e327dd1cbbc3132d51efb88eb52ca9e45461 + packageRevision: sha256:8b2b7ef4d57b9fa78450ddddcfd2054769fc8c01f5d36435a4312c1f268f740c + contractRevision: sha256:f72669730175ad097512fa9eda378bbbd3bbb64a859615e42d4752b277630968 sourceSchemaFingerprints: companies: sha256:dd62b98578f0fa7341eeeaaac4b34da9b79405ae067dc06e5edb004c2d4a38fe artifacts: - - id: audit-event-schema + - accessProfileIdentifier: null + id: audit-event-schema mediaType: application/schema+json operationIdentifier: null path: generated/artifacts/audit-event.schema.json - representationIdentifier: null - sha256: sha256:2b3223ef49813d9b1602317a363a98231978aab34f0b35403c5ef407b6499913 + sha256: sha256:2600120dbc7fbbb0f8d4feaa7cb811055b6f2590ad83c4472d5af982ae004a45 visibility: operator-only - - id: capability-inventory-full + - accessProfileIdentifier: null + id: capability-inventory-full mediaType: application/json operationIdentifier: null path: generated/artifacts/capabilities.full.json - representationIdentifier: null - sha256: sha256:cec26d48f7eced706ac187413f760ef77900bc6b0b527bd9a2d1a885203c1052 + sha256: sha256:af5457dd3c685d914da14b911a4d8d34138bbba695e4b90f871b934ca4bc2f0e visibility: operator-only - - id: capability-inventory + - accessProfileIdentifier: null + id: capability-inventory mediaType: application/json operationIdentifier: null path: generated/artifacts/capabilities.json - representationIdentifier: null - sha256: sha256:6adb3834c7eab77ee86bda24a2348bfb8ddf867ce23087fc1036df0ddc3f0688 + sha256: sha256:eb7db7231f6e902cef236eea3ed32cf074d202ce3a44f56fa2a9c1169f624a08 visibility: public - - id: registered-business--list--representation-public-register-classifications + - accessProfileIdentifier: null + id: registered-business--list--access-profile-public-register-classifications mediaType: application/json operationIdentifier: null - path: generated/artifacts/registered-business--list--representation-public-register.classifications.json - representationIdentifier: null - sha256: sha256:83d76093cb7dd51bc482948b403cf3aa051bc4c4fd1308eed7b9a157da5d19af + path: generated/artifacts/registered-business--list--access-profile-public-register.classifications.json + sha256: sha256:a242a993d24505958b52108869dfce092ec302eb025443a4df0d648cdf67911c visibility: public - - id: registered-business--list--representation-public-register-context + - accessProfileIdentifier: null + id: registered-business--list--access-profile-public-register-context mediaType: application/ld+json operationIdentifier: null - path: generated/artifacts/registered-business--list--representation-public-register.context.jsonld - representationIdentifier: null + path: generated/artifacts/registered-business--list--access-profile-public-register.context.jsonld sha256: sha256:a484835aa45107953b758934d5b9d13e47fc8d7c7a06c11fe709ada7e740f2d2 visibility: public - - id: registered-business--list--representation-public-register-processing + - accessProfileIdentifier: null + id: registered-business--list--access-profile-public-register-processing mediaType: application/json operationIdentifier: null - path: generated/artifacts/registered-business--list--representation-public-register.processing.json - representationIdentifier: null - sha256: sha256:c1e61b89e0a67581c8e75da83853d9d1e0dc5c87aa05e6bd0c02b569f71a8c61 + path: generated/artifacts/registered-business--list--access-profile-public-register.processing.json + sha256: sha256:5f87f5571e1ac60546c0e5da43d0e7396b2a65eafa33782b2ead0e221cd5b333 visibility: public - - id: registered-business--list--representation-public-register-schema + - accessProfileIdentifier: null + id: registered-business--list--access-profile-public-register-schema mediaType: application/schema+json operationIdentifier: null - path: generated/artifacts/registered-business--list--representation-public-register.schema.json - representationIdentifier: null - sha256: sha256:fc9ae1d572bad5863d99b302c6168807ecfbbb09f0977d075096357d60bf7d78 + path: generated/artifacts/registered-business--list--access-profile-public-register.schema.json + sha256: sha256:afb5123323248abcf329b5320cf6a3cb51f4c007b929e1c141c4287cdad91da2 visibility: public - - id: registered-business--list--representation-public-register-shacl + - accessProfileIdentifier: null + id: registered-business--list--access-profile-public-register-shacl mediaType: text/turtle operationIdentifier: null - path: generated/artifacts/registered-business--list--representation-public-register.shacl.ttl - representationIdentifier: null + path: generated/artifacts/registered-business--list--access-profile-public-register.shacl.ttl sha256: sha256:61ac61a72c888c6c1850c16ed11b6ec6be0bb96d8f592fc417759f38a1eaaee0 visibility: public - - id: registered-business--list--representation-public-register-vocabulary + - accessProfileIdentifier: null + id: registered-business--list--access-profile-public-register-vocabulary mediaType: application/ld+json operationIdentifier: null - path: generated/artifacts/registered-business--list--representation-public-register.vocabulary.jsonld - representationIdentifier: null + path: generated/artifacts/registered-business--list--access-profile-public-register.vocabulary.jsonld sha256: sha256:24bcf44aa7b04353a8a23b2d80e5c4fe1cf6a60f0b03d0f0a0c48611631ee5d7 visibility: public - - id: registered-business--list--representation-registrar-capability + - accessProfileIdentifier: registrar + id: registered-business--list--access-profile-registrar-capability mediaType: application/json operationIdentifier: registered-business.list - path: generated/artifacts/registered-business--list--representation-registrar.capability.json - representationIdentifier: registrar - sha256: sha256:08fa0e9dbdf23b056afb56a998b5e5da84085905d365658d9f27e84e5cd73603 + path: generated/artifacts/registered-business--list--access-profile-registrar.capability.json + sha256: sha256:1eeed1145edef2a16a9440a34f54e4118f041b28d24bd79243fd2483cdebd05d visibility: operation-bound - - id: registered-business--list--representation-registrar-classifications + - accessProfileIdentifier: registrar + id: registered-business--list--access-profile-registrar-classifications mediaType: application/json operationIdentifier: registered-business.list - path: generated/artifacts/registered-business--list--representation-registrar.classifications.json - representationIdentifier: registrar - sha256: sha256:7e3f1278d51e1db709068fec463782892985326bb1b9031197626fa2031319d7 + path: generated/artifacts/registered-business--list--access-profile-registrar.classifications.json + sha256: sha256:f4e8deb74f2d80d3f5a5ed946112a8e93055dfc9752c8a90b55d3f882ef49ec9 visibility: operation-bound - - id: registered-business--list--representation-registrar-context + - accessProfileIdentifier: registrar + id: registered-business--list--access-profile-registrar-context mediaType: application/ld+json operationIdentifier: registered-business.list - path: generated/artifacts/registered-business--list--representation-registrar.context.jsonld - representationIdentifier: registrar + path: generated/artifacts/registered-business--list--access-profile-registrar.context.jsonld sha256: sha256:d9c017c057c7228e8145e149494961b7bd8edd4e46c882903af19fbb29d5c960 visibility: operation-bound - - id: registered-business--list--representation-registrar-processing + - accessProfileIdentifier: registrar + id: registered-business--list--access-profile-registrar-processing mediaType: application/json operationIdentifier: registered-business.list - path: generated/artifacts/registered-business--list--representation-registrar.processing.json - representationIdentifier: registrar - sha256: sha256:16edb406f8b2e6fa99e6ad87a8ae64180cc7f7c1006de3060499ddd2a71febb7 + path: generated/artifacts/registered-business--list--access-profile-registrar.processing.json + sha256: sha256:2df97861c32c42672e87d2945d47871b736dfe97b6618243f3a1e1bb357166e5 visibility: operation-bound - - id: registered-business--list--representation-registrar-schema + - accessProfileIdentifier: registrar + id: registered-business--list--access-profile-registrar-schema mediaType: application/schema+json operationIdentifier: registered-business.list - path: generated/artifacts/registered-business--list--representation-registrar.schema.json - representationIdentifier: registrar - sha256: sha256:f2dc9e839f20415ad032c657744db091f732d2dc49133884f4d570a580b3fcc5 + path: generated/artifacts/registered-business--list--access-profile-registrar.schema.json + sha256: sha256:4c13f868cccf249235c9a69fabc81313fd0f3e84e0a01e0d4f5cd72059498f99 visibility: operation-bound - - id: registered-business--list--representation-registrar-shacl + - accessProfileIdentifier: registrar + id: registered-business--list--access-profile-registrar-shacl mediaType: text/turtle operationIdentifier: registered-business.list - path: generated/artifacts/registered-business--list--representation-registrar.shacl.ttl - representationIdentifier: registrar + path: generated/artifacts/registered-business--list--access-profile-registrar.shacl.ttl sha256: sha256:c72599e5a94a0c7a8460b3551ef10506cf9e8fa3bd906c828c4a62092ac54581 visibility: operation-bound - - id: registered-business--list--representation-registrar-vocabulary + - accessProfileIdentifier: registrar + id: registered-business--list--access-profile-registrar-vocabulary mediaType: application/ld+json operationIdentifier: registered-business.list - path: generated/artifacts/registered-business--list--representation-registrar.vocabulary.jsonld - representationIdentifier: registrar + path: generated/artifacts/registered-business--list--access-profile-registrar.vocabulary.jsonld sha256: sha256:1a1d5fec8194398211a8d8ea6618cef48b291b8d42814d94c5cba9af82d84b36 visibility: operation-bound - - id: registered-business--read--representation-public-register-classifications + - accessProfileIdentifier: null + id: registered-business--read--access-profile-public-register-classifications mediaType: application/json operationIdentifier: null - path: generated/artifacts/registered-business--read--representation-public-register.classifications.json - representationIdentifier: null - sha256: sha256:1bb4dc7e114b404dbab14ede384da61f706be65d9f079af0e4bf885c7aa044a4 + path: generated/artifacts/registered-business--read--access-profile-public-register.classifications.json + sha256: sha256:e628697ef0efdac1f00119132f9d592ec29fa328d6a56cece6fb90c80b2a6c4b visibility: public - - id: registered-business--read--representation-public-register-context + - accessProfileIdentifier: null + id: registered-business--read--access-profile-public-register-context mediaType: application/ld+json operationIdentifier: null - path: generated/artifacts/registered-business--read--representation-public-register.context.jsonld - representationIdentifier: null + path: generated/artifacts/registered-business--read--access-profile-public-register.context.jsonld sha256: sha256:a484835aa45107953b758934d5b9d13e47fc8d7c7a06c11fe709ada7e740f2d2 visibility: public - - id: registered-business--read--representation-public-register-processing + - accessProfileIdentifier: null + id: registered-business--read--access-profile-public-register-processing mediaType: application/json operationIdentifier: null - path: generated/artifacts/registered-business--read--representation-public-register.processing.json - representationIdentifier: null - sha256: sha256:a67caa6b24057cf580afa1abe083baaf1d38429ece75d6f2fdada80a86995df4 + path: generated/artifacts/registered-business--read--access-profile-public-register.processing.json + sha256: sha256:c438483b841471785c8393a20648facca8cc970114f894faaf3eca78eac03c6c visibility: public - - id: registered-business--read--representation-public-register-schema + - accessProfileIdentifier: null + id: registered-business--read--access-profile-public-register-schema mediaType: application/schema+json operationIdentifier: null - path: generated/artifacts/registered-business--read--representation-public-register.schema.json - representationIdentifier: null - sha256: sha256:e57c261ade7808c2974aa86d0bd2d16ac10038b2fe2e0e3b08065099b0e7607c + path: generated/artifacts/registered-business--read--access-profile-public-register.schema.json + sha256: sha256:c591f600996b38d0af3c81e77e5eb72805ab70ed10e464fada3646a768bfd785 visibility: public - - id: registered-business--read--representation-public-register-shacl + - accessProfileIdentifier: null + id: registered-business--read--access-profile-public-register-shacl mediaType: text/turtle operationIdentifier: null - path: generated/artifacts/registered-business--read--representation-public-register.shacl.ttl - representationIdentifier: null + path: generated/artifacts/registered-business--read--access-profile-public-register.shacl.ttl sha256: sha256:61ac61a72c888c6c1850c16ed11b6ec6be0bb96d8f592fc417759f38a1eaaee0 visibility: public - - id: registered-business--read--representation-public-register-vocabulary + - accessProfileIdentifier: null + id: registered-business--read--access-profile-public-register-vocabulary mediaType: application/ld+json operationIdentifier: null - path: generated/artifacts/registered-business--read--representation-public-register.vocabulary.jsonld - representationIdentifier: null + path: generated/artifacts/registered-business--read--access-profile-public-register.vocabulary.jsonld sha256: sha256:24bcf44aa7b04353a8a23b2d80e5c4fe1cf6a60f0b03d0f0a0c48611631ee5d7 visibility: public - - id: registered-business--read--representation-registrar-capability + - accessProfileIdentifier: registrar + id: registered-business--read--access-profile-registrar-capability mediaType: application/json operationIdentifier: registered-business.read - path: generated/artifacts/registered-business--read--representation-registrar.capability.json - representationIdentifier: registrar - sha256: sha256:4b0874aa8385cc40a92d8a85c5678aaf71b5a3bd1beca76dc42bdb11484e8487 + path: generated/artifacts/registered-business--read--access-profile-registrar.capability.json + sha256: sha256:bae49b54fbe55e83a870aad8041bc49d36d1c30a336c6c0c55916c6a84d55392 visibility: operation-bound - - id: registered-business--read--representation-registrar-classifications + - accessProfileIdentifier: registrar + id: registered-business--read--access-profile-registrar-classifications mediaType: application/json operationIdentifier: registered-business.read - path: generated/artifacts/registered-business--read--representation-registrar.classifications.json - representationIdentifier: registrar - sha256: sha256:a7759fac756bcf40c15e3f3635030f115f25c0971a8c8969721f634b7ed3e5de + path: generated/artifacts/registered-business--read--access-profile-registrar.classifications.json + sha256: sha256:a447eae6a497e23ab720dfd67767a9665b55e231157a45766460452aeb6a9ff0 visibility: operation-bound - - id: registered-business--read--representation-registrar-context + - accessProfileIdentifier: registrar + id: registered-business--read--access-profile-registrar-context mediaType: application/ld+json operationIdentifier: registered-business.read - path: generated/artifacts/registered-business--read--representation-registrar.context.jsonld - representationIdentifier: registrar + path: generated/artifacts/registered-business--read--access-profile-registrar.context.jsonld sha256: sha256:d9c017c057c7228e8145e149494961b7bd8edd4e46c882903af19fbb29d5c960 visibility: operation-bound - - id: registered-business--read--representation-registrar-processing + - accessProfileIdentifier: registrar + id: registered-business--read--access-profile-registrar-processing mediaType: application/json operationIdentifier: registered-business.read - path: generated/artifacts/registered-business--read--representation-registrar.processing.json - representationIdentifier: registrar - sha256: sha256:8070e0a92f64b68995e1f41e8cf64231425e92471267550173d42ee0104ef1ee + path: generated/artifacts/registered-business--read--access-profile-registrar.processing.json + sha256: sha256:cdfd9e044be2f18addbe824be6f4a0a07379364542577987833759bd48e038ee visibility: operation-bound - - id: registered-business--read--representation-registrar-schema + - accessProfileIdentifier: registrar + id: registered-business--read--access-profile-registrar-schema mediaType: application/schema+json operationIdentifier: registered-business.read - path: generated/artifacts/registered-business--read--representation-registrar.schema.json - representationIdentifier: registrar - sha256: sha256:aa6b7009b4a5aa191cce6c057b6a4f3ee7696768c7e21ff8a41802a2f3b44dba + path: generated/artifacts/registered-business--read--access-profile-registrar.schema.json + sha256: sha256:e183ab8c5f2432d46b6f77f91f57503c7cd60eb904e1913b45d491092d04f6a3 visibility: operation-bound - - id: registered-business--read--representation-registrar-shacl + - accessProfileIdentifier: registrar + id: registered-business--read--access-profile-registrar-shacl mediaType: text/turtle operationIdentifier: registered-business.read - path: generated/artifacts/registered-business--read--representation-registrar.shacl.ttl - representationIdentifier: registrar + path: generated/artifacts/registered-business--read--access-profile-registrar.shacl.ttl sha256: sha256:c72599e5a94a0c7a8460b3551ef10506cf9e8fa3bd906c828c4a62092ac54581 visibility: operation-bound - - id: registered-business--read--representation-registrar-vocabulary + - accessProfileIdentifier: registrar + id: registered-business--read--access-profile-registrar-vocabulary mediaType: application/ld+json operationIdentifier: registered-business.read - path: generated/artifacts/registered-business--read--representation-registrar.vocabulary.jsonld - representationIdentifier: registrar + path: generated/artifacts/registered-business--read--access-profile-registrar.vocabulary.jsonld sha256: sha256:1a1d5fec8194398211a8d8ea6618cef48b291b8d42814d94c5cba9af82d84b36 visibility: operation-bound - - id: registered-business-classification + - accessProfileIdentifier: null + id: registered-business-classification mediaType: application/json operationIdentifier: null path: generated/artifacts/registered-business.classifications.json - representationIdentifier: null sha256: sha256:0eba6b9824ab9b0e21482bf49cecc6db1401073f2103df2ca57fd93b1383915a visibility: operator-only - - id: registered-business-codelist-0 + - accessProfileIdentifier: null + id: registered-business-codelist-0 mediaType: application/schema+json operationIdentifier: null path: generated/artifacts/registered-business.codelist-0.schema.json - representationIdentifier: null sha256: sha256:b5f27954974850cd56ec6e271a4f630ce749efc332e58b6a407ece3f943f3d20 visibility: operator-only - - id: registered-business-codelist-1 + - accessProfileIdentifier: null + id: registered-business-codelist-1 mediaType: application/schema+json operationIdentifier: null path: generated/artifacts/registered-business.codelist-1.schema.json - representationIdentifier: null sha256: sha256:69064b6563a9376270b2d6535a338a5766071012817d25edb0351f8e0e65b76b visibility: operator-only - - id: registered-business-codelist-2 + - accessProfileIdentifier: null + id: registered-business-codelist-2 mediaType: application/schema+json operationIdentifier: null path: generated/artifacts/registered-business.codelist-2.schema.json - representationIdentifier: null sha256: sha256:4df390c7d6dbf8dae80011b4ea93545b7f2688cc7337a0534f322a92530d3b96 visibility: operator-only - - id: registered-business-codelist-3 + - accessProfileIdentifier: null + id: registered-business-codelist-3 mediaType: application/schema+json operationIdentifier: null path: generated/artifacts/registered-business.codelist-3.schema.json - representationIdentifier: null sha256: sha256:e42dfbcab45a66032d126e0f203523ae44a6bc034278f2ce222f96f1ff0a78f0 visibility: operator-only - - id: registered-business-full-schema + - accessProfileIdentifier: null + id: registered-business-full-schema mediaType: application/schema+json operationIdentifier: null path: generated/artifacts/registered-business.full.schema.json - representationIdentifier: null sha256: sha256:8266c4c05a0c304d255de8c69e78bac01b6d2ad86d540cde95e234ca775878fa visibility: operator-only - - id: registered-business-full-shacl + - accessProfileIdentifier: null + id: registered-business-full-shacl mediaType: text/turtle operationIdentifier: null path: generated/artifacts/registered-business.full.shacl.ttl - representationIdentifier: null sha256: sha256:5800a8e5dc107a5d7260e2567e36504afded088c3335af1a53d0969fdb099270 visibility: operator-only - - id: registered-business-full-vocabulary + - accessProfileIdentifier: null + id: registered-business-full-vocabulary mediaType: application/ld+json operationIdentifier: null path: generated/artifacts/registered-business.full.vocabulary.jsonld - representationIdentifier: null sha256: sha256:f57ec119ca7d4dc0534ee8e2c5f8756e336f0f90bd34e22fab18f731baffe181 visibility: operator-only - - id: registered-business-processing-full + - accessProfileIdentifier: null + id: registered-business-processing-full mediaType: application/json operationIdentifier: null path: generated/artifacts/registered-business.processing.full.json - representationIdentifier: null sha256: sha256:9e14c3d53958f18e29ee021c74f6f8ea0ceacb0452d01f5f13f5ea7270006158 visibility: operator-only - - id: registered-premises--list--representation-public-premises-classifications + - accessProfileIdentifier: registrar-premises + id: registered-premises--list--access-profile-registrar-premises-capability + mediaType: application/json + operationIdentifier: registered-premises.list + path: generated/artifacts/registered-premises--list--access-profile-registrar-premises.capability.json + sha256: sha256:895f7603bb4d4bd0c69eebb00b01af0469e8a8cec36fef4e1f5d6b9a2884dd1f + visibility: operation-bound + - accessProfileIdentifier: registrar-premises + id: registered-premises--list--access-profile-registrar-premises-classifications + mediaType: application/json + operationIdentifier: registered-premises.list + path: generated/artifacts/registered-premises--list--access-profile-registrar-premises.classifications.json + sha256: sha256:98786ecd12e6c651e702cf06bca640a3caf92622d0b6a5faa0eb4fd0455a9212 + visibility: operation-bound + - accessProfileIdentifier: registrar-premises + id: registered-premises--list--access-profile-registrar-premises-context + mediaType: application/ld+json + operationIdentifier: registered-premises.list + path: generated/artifacts/registered-premises--list--access-profile-registrar-premises.context.jsonld + sha256: sha256:93d1989d92502293a18f4e9845094fedf8ff96a5ebb91fddaab5728cb1cd9161 + visibility: operation-bound + - accessProfileIdentifier: registrar-premises + id: registered-premises--list--access-profile-registrar-premises-geojson-schema + mediaType: application/schema+json + operationIdentifier: registered-premises.list + path: generated/artifacts/registered-premises--list--access-profile-registrar-premises.geojson.schema.json + sha256: sha256:f460e3faf3707a247f5ff33faa2ae48bb2e517426fab5de1db7b7b48634dea74 + visibility: operation-bound + - accessProfileIdentifier: registrar-premises + id: registered-premises--list--access-profile-registrar-premises-processing + mediaType: application/json + operationIdentifier: registered-premises.list + path: generated/artifacts/registered-premises--list--access-profile-registrar-premises.processing.json + sha256: sha256:a25a0dd195a49fd20617134557c5ae679326da397f4ee5a7a639e85412cf2cc5 + visibility: operation-bound + - accessProfileIdentifier: registrar-premises + id: registered-premises--list--access-profile-registrar-premises-schema + mediaType: application/schema+json + operationIdentifier: registered-premises.list + path: generated/artifacts/registered-premises--list--access-profile-registrar-premises.schema.json + sha256: sha256:0a141f9ee94d5c5f68642a3269e3255a80481f856e32f412a2da534fe7b01fb3 + visibility: operation-bound + - accessProfileIdentifier: registrar-premises + id: registered-premises--list--access-profile-registrar-premises-shacl + mediaType: text/turtle + operationIdentifier: registered-premises.list + path: generated/artifacts/registered-premises--list--access-profile-registrar-premises.shacl.ttl + sha256: sha256:56b593e9b20700ee37257de5ea749366deadf6e1d760cc3da5e90ce28f955e8c + visibility: operation-bound + - accessProfileIdentifier: registrar-premises + id: registered-premises--list--access-profile-registrar-premises-vocabulary + mediaType: application/ld+json + operationIdentifier: registered-premises.list + path: generated/artifacts/registered-premises--list--access-profile-registrar-premises.vocabulary.jsonld + sha256: sha256:3af77a0e9a5c087638b560ff6da1c886d0e6ab11f5961e6882d4cb69b60fb994 + visibility: operation-bound + - accessProfileIdentifier: null + id: registered-premises--read--access-profile-public-premises-classifications mediaType: application/json operationIdentifier: null - path: generated/artifacts/registered-premises--list--representation-public-premises.classifications.json - representationIdentifier: null - sha256: sha256:79050bbc149ff904fc4a8623409ccce3ef03e707d72d8f3ae21836281343d281 + path: generated/artifacts/registered-premises--read--access-profile-public-premises.classifications.json + sha256: sha256:896e33b6d94776ae3196eeb68c7cae02c592af3aad65326519757c8d79723805 visibility: public - - id: registered-premises--list--representation-public-premises-context + - accessProfileIdentifier: null + id: registered-premises--read--access-profile-public-premises-context mediaType: application/ld+json operationIdentifier: null - path: generated/artifacts/registered-premises--list--representation-public-premises.context.jsonld - representationIdentifier: null - sha256: sha256:93d1989d92502293a18f4e9845094fedf8ff96a5ebb91fddaab5728cb1cd9161 + path: generated/artifacts/registered-premises--read--access-profile-public-premises.context.jsonld + sha256: sha256:9e5459f441ec270ed225e0f6a8e420a4105fbca0b189e16088aef38a11a5af12 visibility: public - - id: registered-premises--list--representation-public-premises-geojson-schema + - accessProfileIdentifier: null + id: registered-premises--read--access-profile-public-premises-geojson-schema mediaType: application/schema+json operationIdentifier: null - path: generated/artifacts/registered-premises--list--representation-public-premises.geojson.schema.json - representationIdentifier: null - sha256: sha256:2c37c844b603a6268e5605a6d8851d775ec1bd3fea15c2a65be8544489b93a4c + path: generated/artifacts/registered-premises--read--access-profile-public-premises.geojson.schema.json + sha256: sha256:b0f0dff92c8743dd34d04b6f861a7aa549a73f3d6a3aedbf6b34874e5f82aee2 visibility: public - - id: registered-premises--list--representation-public-premises-processing + - accessProfileIdentifier: null + id: registered-premises--read--access-profile-public-premises-processing mediaType: application/json operationIdentifier: null - path: generated/artifacts/registered-premises--list--representation-public-premises.processing.json - representationIdentifier: null - sha256: sha256:14685ccde064a982f643b8165477abf0ae37b485f7bb6d5fdbc10a77cf6d9577 + path: generated/artifacts/registered-premises--read--access-profile-public-premises.processing.json + sha256: sha256:8d8af9e03e0ee99008eea2d865678fb330db321f2f01c416de5f6401de3b38fd visibility: public - - id: registered-premises--list--representation-public-premises-schema + - accessProfileIdentifier: null + id: registered-premises--read--access-profile-public-premises-schema mediaType: application/schema+json operationIdentifier: null - path: generated/artifacts/registered-premises--list--representation-public-premises.schema.json - representationIdentifier: null - sha256: sha256:2482dcc6fdf6099706e812323a3e5b6244460c298e528783308d1022ae813b05 + path: generated/artifacts/registered-premises--read--access-profile-public-premises.schema.json + sha256: sha256:99bf707298df6740f53a68ab8234865327be245efd472e03a02e07ae3a7ba27d visibility: public - - id: registered-premises--list--representation-public-premises-shacl + - accessProfileIdentifier: null + id: registered-premises--read--access-profile-public-premises-shacl mediaType: text/turtle operationIdentifier: null - path: generated/artifacts/registered-premises--list--representation-public-premises.shacl.ttl - representationIdentifier: null - sha256: sha256:56b593e9b20700ee37257de5ea749366deadf6e1d760cc3da5e90ce28f955e8c + path: generated/artifacts/registered-premises--read--access-profile-public-premises.shacl.ttl + sha256: sha256:d31ea35d3e00da273beb83c53d2848d58114fdddfb946e01489f5334f7f70c99 visibility: public - - id: registered-premises--list--representation-public-premises-vocabulary + - accessProfileIdentifier: null + id: registered-premises--read--access-profile-public-premises-vocabulary mediaType: application/ld+json operationIdentifier: null - path: generated/artifacts/registered-premises--list--representation-public-premises.vocabulary.jsonld - representationIdentifier: null - sha256: sha256:3af77a0e9a5c087638b560ff6da1c886d0e6ab11f5961e6882d4cb69b60fb994 + path: generated/artifacts/registered-premises--read--access-profile-public-premises.vocabulary.jsonld + sha256: sha256:2f2d975c8456d4a7288b5a5f9e44fda8d4b19bf803e05254e446cbbc6e62cb29 visibility: public - - id: registered-premises--read--representation-public-premises-classifications + - accessProfileIdentifier: registrar-premises + id: registered-premises--read--access-profile-registrar-premises-capability + mediaType: application/json + operationIdentifier: registered-premises.read + path: generated/artifacts/registered-premises--read--access-profile-registrar-premises.capability.json + sha256: sha256:b0fb543ad7f96e0715ccad693cae22bf02fe1e27b67a6b5bfe7c4755924d80a2 + visibility: operation-bound + - accessProfileIdentifier: registrar-premises + id: registered-premises--read--access-profile-registrar-premises-classifications + mediaType: application/json + operationIdentifier: registered-premises.read + path: generated/artifacts/registered-premises--read--access-profile-registrar-premises.classifications.json + sha256: sha256:1c19c15c8451408c8419217c25f1da650d9c1178f1aa56162a7393a937a04bb3 + visibility: operation-bound + - accessProfileIdentifier: registrar-premises + id: registered-premises--read--access-profile-registrar-premises-context + mediaType: application/ld+json + operationIdentifier: registered-premises.read + path: generated/artifacts/registered-premises--read--access-profile-registrar-premises.context.jsonld + sha256: sha256:93d1989d92502293a18f4e9845094fedf8ff96a5ebb91fddaab5728cb1cd9161 + visibility: operation-bound + - accessProfileIdentifier: registrar-premises + id: registered-premises--read--access-profile-registrar-premises-geojson-schema + mediaType: application/schema+json + operationIdentifier: registered-premises.read + path: generated/artifacts/registered-premises--read--access-profile-registrar-premises.geojson.schema.json + sha256: sha256:5e760860fc5adf013592c4174330fb9983425f3aac4bd67dc98d2003fd0daf68 + visibility: operation-bound + - accessProfileIdentifier: registrar-premises + id: registered-premises--read--access-profile-registrar-premises-processing + mediaType: application/json + operationIdentifier: registered-premises.read + path: generated/artifacts/registered-premises--read--access-profile-registrar-premises.processing.json + sha256: sha256:220845cbffa466f53025e7e6b8e55fcd11589b8631b6ff3c39ce0a051d643880 + visibility: operation-bound + - accessProfileIdentifier: registrar-premises + id: registered-premises--read--access-profile-registrar-premises-schema + mediaType: application/schema+json + operationIdentifier: registered-premises.read + path: generated/artifacts/registered-premises--read--access-profile-registrar-premises.schema.json + sha256: sha256:934d98a913d9947aab244ab49891d1c3891620dfd76ac9c3e7aeae8954eed93c + visibility: operation-bound + - accessProfileIdentifier: registrar-premises + id: registered-premises--read--access-profile-registrar-premises-shacl + mediaType: text/turtle + operationIdentifier: registered-premises.read + path: generated/artifacts/registered-premises--read--access-profile-registrar-premises.shacl.ttl + sha256: sha256:56b593e9b20700ee37257de5ea749366deadf6e1d760cc3da5e90ce28f955e8c + visibility: operation-bound + - accessProfileIdentifier: registrar-premises + id: registered-premises--read--access-profile-registrar-premises-vocabulary + mediaType: application/ld+json + operationIdentifier: registered-premises.read + path: generated/artifacts/registered-premises--read--access-profile-registrar-premises.vocabulary.jsonld + sha256: sha256:3af77a0e9a5c087638b560ff6da1c886d0e6ab11f5961e6882d4cb69b60fb994 + visibility: operation-bound + - accessProfileIdentifier: null + id: registered-premises--search-within-bbox--access-profile-public-premises-classifications mediaType: application/json operationIdentifier: null - path: generated/artifacts/registered-premises--read--representation-public-premises.classifications.json - representationIdentifier: null - sha256: sha256:9ba4c3330ed6c9bfc86ff0622636b26fb071353fd7c9378d911db58ec59d52cd + path: generated/artifacts/registered-premises--search-within-bbox--access-profile-public-premises.classifications.json + sha256: sha256:ec2622189784ead9216c3e4ca5117473eb7bc2111a7c5aac25a092fc3459b856 visibility: public - - id: registered-premises--read--representation-public-premises-context + - accessProfileIdentifier: null + id: registered-premises--search-within-bbox--access-profile-public-premises-context mediaType: application/ld+json operationIdentifier: null - path: generated/artifacts/registered-premises--read--representation-public-premises.context.jsonld - representationIdentifier: null - sha256: sha256:93d1989d92502293a18f4e9845094fedf8ff96a5ebb91fddaab5728cb1cd9161 + path: generated/artifacts/registered-premises--search-within-bbox--access-profile-public-premises.context.jsonld + sha256: sha256:9e5459f441ec270ed225e0f6a8e420a4105fbca0b189e16088aef38a11a5af12 visibility: public - - id: registered-premises--read--representation-public-premises-geojson-schema + - accessProfileIdentifier: null + id: registered-premises--search-within-bbox--access-profile-public-premises-geojson-schema mediaType: application/schema+json operationIdentifier: null - path: generated/artifacts/registered-premises--read--representation-public-premises.geojson.schema.json - representationIdentifier: null - sha256: sha256:8365aedc8a50a39831d53c12e2180d20ac2e3e34f0c3e62c0d9e6afe4b3f3a46 + path: generated/artifacts/registered-premises--search-within-bbox--access-profile-public-premises.geojson.schema.json + sha256: sha256:2f78765736ff332456e84250cc466e1f0b6c2b42ff9ba968e7b80b5e4aec80ea visibility: public - - id: registered-premises--read--representation-public-premises-processing + - accessProfileIdentifier: null + id: registered-premises--search-within-bbox--access-profile-public-premises-processing mediaType: application/json operationIdentifier: null - path: generated/artifacts/registered-premises--read--representation-public-premises.processing.json - representationIdentifier: null - sha256: sha256:bd960527a4b1ecfa83d89eaa9a77d161fd22e15513cd18784765e35230ae22bd + path: generated/artifacts/registered-premises--search-within-bbox--access-profile-public-premises.processing.json + sha256: sha256:1346f2361d748c1af103a515ee09642cc588a3919d48351a66db4a42d9e093ec visibility: public - - id: registered-premises--read--representation-public-premises-schema + - accessProfileIdentifier: null + id: registered-premises--search-within-bbox--access-profile-public-premises-schema mediaType: application/schema+json operationIdentifier: null - path: generated/artifacts/registered-premises--read--representation-public-premises.schema.json - representationIdentifier: null - sha256: sha256:8604d66989f9ed7830a922678b73bd6cf1334283e2f9c3e0d482a2553ca9c70b + path: generated/artifacts/registered-premises--search-within-bbox--access-profile-public-premises.schema.json + sha256: sha256:1ad230a76cef7a32358732aa8a960263078c7b24af2bdbff6b150f77f45db8a1 visibility: public - - id: registered-premises--read--representation-public-premises-shacl + - accessProfileIdentifier: null + id: registered-premises--search-within-bbox--access-profile-public-premises-shacl mediaType: text/turtle operationIdentifier: null - path: generated/artifacts/registered-premises--read--representation-public-premises.shacl.ttl - representationIdentifier: null - sha256: sha256:56b593e9b20700ee37257de5ea749366deadf6e1d760cc3da5e90ce28f955e8c + path: generated/artifacts/registered-premises--search-within-bbox--access-profile-public-premises.shacl.ttl + sha256: sha256:d31ea35d3e00da273beb83c53d2848d58114fdddfb946e01489f5334f7f70c99 visibility: public - - id: registered-premises--read--representation-public-premises-vocabulary + - accessProfileIdentifier: null + id: registered-premises--search-within-bbox--access-profile-public-premises-vocabulary mediaType: application/ld+json operationIdentifier: null - path: generated/artifacts/registered-premises--read--representation-public-premises.vocabulary.jsonld - representationIdentifier: null - sha256: sha256:3af77a0e9a5c087638b560ff6da1c886d0e6ab11f5961e6882d4cb69b60fb994 + path: generated/artifacts/registered-premises--search-within-bbox--access-profile-public-premises.vocabulary.jsonld + sha256: sha256:2f2d975c8456d4a7288b5a5f9e44fda8d4b19bf803e05254e446cbbc6e62cb29 visibility: public - - id: registered-premises-classification + - accessProfileIdentifier: registrar-premises + id: registered-premises--search-within-bbox--access-profile-registrar-premises-capability + mediaType: application/json + operationIdentifier: registered-premises.search.within-bbox + path: generated/artifacts/registered-premises--search-within-bbox--access-profile-registrar-premises.capability.json + sha256: sha256:35eede13bf9ee7c706a853d1f266cd45d9becb888ab295b8ea46e8e2381ee755 + visibility: operation-bound + - accessProfileIdentifier: registrar-premises + id: registered-premises--search-within-bbox--access-profile-registrar-premises-classifications + mediaType: application/json + operationIdentifier: registered-premises.search.within-bbox + path: generated/artifacts/registered-premises--search-within-bbox--access-profile-registrar-premises.classifications.json + sha256: sha256:84c96fca9c185021d61e9bad0cdb1126804f8bc04013e281594b01832eceb1b3 + visibility: operation-bound + - accessProfileIdentifier: registrar-premises + id: registered-premises--search-within-bbox--access-profile-registrar-premises-context + mediaType: application/ld+json + operationIdentifier: registered-premises.search.within-bbox + path: generated/artifacts/registered-premises--search-within-bbox--access-profile-registrar-premises.context.jsonld + sha256: sha256:93d1989d92502293a18f4e9845094fedf8ff96a5ebb91fddaab5728cb1cd9161 + visibility: operation-bound + - accessProfileIdentifier: registrar-premises + id: registered-premises--search-within-bbox--access-profile-registrar-premises-geojson-schema + mediaType: application/schema+json + operationIdentifier: registered-premises.search.within-bbox + path: generated/artifacts/registered-premises--search-within-bbox--access-profile-registrar-premises.geojson.schema.json + sha256: sha256:e2fbb935927aab40cf6bb48ee69431fe9a385a96a958b3c99b7006fbcc90173f + visibility: operation-bound + - accessProfileIdentifier: registrar-premises + id: registered-premises--search-within-bbox--access-profile-registrar-premises-processing + mediaType: application/json + operationIdentifier: registered-premises.search.within-bbox + path: generated/artifacts/registered-premises--search-within-bbox--access-profile-registrar-premises.processing.json + sha256: sha256:0b77612ff0279d2f96aab1577d3a98b7131ebcbcdc662222cfa4a0d4b3ce248e + visibility: operation-bound + - accessProfileIdentifier: registrar-premises + id: registered-premises--search-within-bbox--access-profile-registrar-premises-schema + mediaType: application/schema+json + operationIdentifier: registered-premises.search.within-bbox + path: generated/artifacts/registered-premises--search-within-bbox--access-profile-registrar-premises.schema.json + sha256: sha256:d2e02850800cea94295ead3a2fb6dd633d3f4240c996e6f7877d8b16c628af80 + visibility: operation-bound + - accessProfileIdentifier: registrar-premises + id: registered-premises--search-within-bbox--access-profile-registrar-premises-shacl + mediaType: text/turtle + operationIdentifier: registered-premises.search.within-bbox + path: generated/artifacts/registered-premises--search-within-bbox--access-profile-registrar-premises.shacl.ttl + sha256: sha256:56b593e9b20700ee37257de5ea749366deadf6e1d760cc3da5e90ce28f955e8c + visibility: operation-bound + - accessProfileIdentifier: registrar-premises + id: registered-premises--search-within-bbox--access-profile-registrar-premises-vocabulary + mediaType: application/ld+json + operationIdentifier: registered-premises.search.within-bbox + path: generated/artifacts/registered-premises--search-within-bbox--access-profile-registrar-premises.vocabulary.jsonld + sha256: sha256:3af77a0e9a5c087638b560ff6da1c886d0e6ab11f5961e6882d4cb69b60fb994 + visibility: operation-bound + - accessProfileIdentifier: null + id: registered-premises-classification mediaType: application/json operationIdentifier: null path: generated/artifacts/registered-premises.classifications.json - representationIdentifier: null - sha256: sha256:2e3d1a8bbcbf15b20575d050ef4a9dad65284e777856b83369f2948c5b2a3f7f + sha256: sha256:6894b5990a0ffc7e008b197fc150d6ce19440317ba211dbca5ce4417b38d7e33 visibility: operator-only - - id: registered-premises-codelist-0 + - accessProfileIdentifier: null + id: registered-premises-codelist-0 mediaType: application/schema+json operationIdentifier: null path: generated/artifacts/registered-premises.codelist-0.schema.json - representationIdentifier: null sha256: sha256:e42dfbcab45a66032d126e0f203523ae44a6bc034278f2ce222f96f1ff0a78f0 visibility: operator-only - - id: registered-premises-full-schema + - accessProfileIdentifier: null + id: registered-premises-full-schema mediaType: application/schema+json operationIdentifier: null path: generated/artifacts/registered-premises.full.schema.json - representationIdentifier: null sha256: sha256:7235a46db233c30dfdb4bb22834a111e106bd7768db273976639775664728b18 visibility: operator-only - - id: registered-premises-full-shacl + - accessProfileIdentifier: null + id: registered-premises-full-shacl mediaType: text/turtle operationIdentifier: null path: generated/artifacts/registered-premises.full.shacl.ttl - representationIdentifier: null sha256: sha256:b138dab8da2dcda7fb717bbcab3ca20dd45dfa7dd24e39eb13af05b2472bd5fe visibility: operator-only - - id: registered-premises-full-vocabulary + - accessProfileIdentifier: null + id: registered-premises-full-vocabulary mediaType: application/ld+json operationIdentifier: null path: generated/artifacts/registered-premises.full.vocabulary.jsonld - representationIdentifier: null sha256: sha256:3af77a0e9a5c087638b560ff6da1c886d0e6ab11f5961e6882d4cb69b60fb994 visibility: operator-only - - id: registered-premises-processing-full + - accessProfileIdentifier: null + id: registered-premises-processing-full mediaType: application/json operationIdentifier: null path: generated/artifacts/registered-premises.processing.full.json - representationIdentifier: null - sha256: sha256:e231deecb5fce85a8bc492a23df1a3c5bdcdf37a4d19329ce994024efcdb35a9 + sha256: sha256:68d4d19f9cd242b1344adf7862fff9579b0a254c78146c48efc5961f5a452385 visibility: operator-only - - id: openapi-full + - accessProfileIdentifier: null + id: openapi-full mediaType: application/yaml operationIdentifier: null path: generated/openapi.full.yaml - representationIdentifier: null - sha256: sha256:8766ffc1ade253b49dd8f28b8f8cb015fe12689fa23aa2cd9860db64bdad3f8c + sha256: sha256:0f632e9d0751702b6ab682faca7870909a1e375852db214320de8841c5b62979 visibility: operator-only - - id: openapi-public + - accessProfileIdentifier: null + id: openapi-public mediaType: application/json operationIdentifier: null path: generated/openapi.public.json - representationIdentifier: null - sha256: sha256:09636233393eed2ae33fb1755db8bb617064c72dafbb2e5d83a93fe782825c6a + sha256: sha256:d8cda77c932d8514ab32236d425957bef50d03cc0c16b6bd712939d3e0abd4de visibility: public governedFiles: - generated: false @@ -705,13 +873,13 @@ projects: - generated: false mediaType: application/yaml path: governed/governance/classification-review-rationale.md - sha256: sha256:43c20bf9303933e3df2fa6cc753fe80b4aa755c272dda3e67c483c70ffea52cd + sha256: sha256:f7990fb19be17029896efa2b036b4bf8a01eaea42ac49b159604d1ec0bf7bb81 size: 227 visibility: operator-only - generated: false mediaType: application/yaml path: governed/governance/classification-review.yaml - sha256: sha256:4ab06258e697172dc87ca22940316d75933c65f9645ef4e35d78fab2c82aab3c + sha256: sha256:24f31562b106281ec578657f5e0c70df2540332f26485fd246b7b9abaeba12cd size: 423 visibility: operator-only - generated: false @@ -735,258 +903,258 @@ projects: - generated: false mediaType: application/yaml path: registry.yaml - sha256: sha256:6cd9dd4b7d58a55d944092dc22eee69a60e02339d52351a69fd6fc85556d6b23 - size: 10159 + sha256: sha256:70e3d53e8f6ce3522669bff7ffe480754574ed529fb068b35008e9d6da90f606 + size: 10965 visibility: operator-only civil-event: - packageRevision: sha256:c90611d7542534408fceb4f921a144004241e85e6fb745a1a07878cf4d8faaa4 - contractRevision: sha256:44a3604af965eb51983e44c8cebbb2486cd0655bfe265ce8a47cef22c8f1246a + packageRevision: sha256:6b7ee913278e9bca570868cd2ac3a8e7cf60886a7549438005dd49774e65087e + contractRevision: sha256:011d151e19402f7c4b4bd02c10be4afa93567c5e0cc1e6db28a03a7d1560e8e0 sourceSchemaFingerprints: events: sha256:7f770d64cb19ec54caca2aa56378b13a43cd5edc206ff44b5fecc99ee9e63759 artifacts: - - id: audit-event-schema + - accessProfileIdentifier: null + id: audit-event-schema mediaType: application/schema+json operationIdentifier: null path: generated/artifacts/audit-event.schema.json - representationIdentifier: null - sha256: sha256:2b3223ef49813d9b1602317a363a98231978aab34f0b35403c5ef407b6499913 + sha256: sha256:2600120dbc7fbbb0f8d4feaa7cb811055b6f2590ad83c4472d5af982ae004a45 visibility: operator-only - - id: capability-inventory-full + - accessProfileIdentifier: null + id: capability-inventory-full mediaType: application/json operationIdentifier: null path: generated/artifacts/capabilities.full.json - representationIdentifier: null - sha256: sha256:a830d6a256ee465e0f790671adb6e73190dbaeae6a180a3efe7995246080a9be + sha256: sha256:7193f71e6de3878e0441490fddbd4dad1ee0135fb41cc5f7333ba039c307da03 visibility: operator-only - - id: capability-inventory + - accessProfileIdentifier: null + id: capability-inventory mediaType: application/json operationIdentifier: null path: generated/artifacts/capabilities.json - representationIdentifier: null - sha256: sha256:c944ae58e0b91a404d70d492d46a1fd0ab45a7a455140a65642378c6fe57cdd6 + sha256: sha256:2d19a8c1ae7c66dd2832ea75df6baaea30c8eacbc523d8a9f7405393cefffdac visibility: public - - id: civil-event--lookup-verify-registration--representation-registrar-verification-capability + - accessProfileIdentifier: registrar-verification + id: civil-event--lookup-verify-registration--access-profile-registrar-verification-capability mediaType: application/json operationIdentifier: civil-event.lookup.verify-registration - path: generated/artifacts/civil-event--lookup-verify-registration--representation-registrar-verification.capability.json - representationIdentifier: registrar-verification - sha256: sha256:05b573b81da9abfc5043aa542f7dfe756a0da1192ab9b0640b31b809fcfa8b1b + path: generated/artifacts/civil-event--lookup-verify-registration--access-profile-registrar-verification.capability.json + sha256: sha256:b88209048ded9b3dd3ce7c945604bfaa401849e4fa2c0991a33e62e5d2104f8a visibility: operation-bound - - id: civil-event--lookup-verify-registration--representation-registrar-verification-classifications + - accessProfileIdentifier: null + id: civil-event--lookup-verify-registration--access-profile-registrar-verification-classifications mediaType: application/json operationIdentifier: null - path: generated/artifacts/civil-event--lookup-verify-registration--representation-registrar-verification.classifications.json - representationIdentifier: null - sha256: sha256:1d8d951dac6336b3fd1b1a0842d5f90f98eb5500e932a7ac697365790a4df752 + path: generated/artifacts/civil-event--lookup-verify-registration--access-profile-registrar-verification.classifications.json + sha256: sha256:308553521c3108baf781299bd10c25eec92f71f8eb0dcee1b5211dd953968d2a visibility: operator-only - - id: civil-event--lookup-verify-registration--representation-registrar-verification-context + - accessProfileIdentifier: registrar-verification + id: civil-event--lookup-verify-registration--access-profile-registrar-verification-context mediaType: application/ld+json operationIdentifier: civil-event.lookup.verify-registration - path: generated/artifacts/civil-event--lookup-verify-registration--representation-registrar-verification.context.jsonld - representationIdentifier: registrar-verification + path: generated/artifacts/civil-event--lookup-verify-registration--access-profile-registrar-verification.context.jsonld sha256: sha256:cecc395f6eab42ed11603ded1b76d25980ede8fbe9e2b9adb02a71b8c3a4e423 visibility: operation-bound - - id: civil-event--lookup-verify-registration--representation-registrar-verification-processing + - accessProfileIdentifier: registrar-verification + id: civil-event--lookup-verify-registration--access-profile-registrar-verification-processing mediaType: application/json operationIdentifier: civil-event.lookup.verify-registration - path: generated/artifacts/civil-event--lookup-verify-registration--representation-registrar-verification.processing.json - representationIdentifier: registrar-verification - sha256: sha256:2e9819882cafdef85388ef5775f3c97a9f17fc9b9c4c1fd15e1e156be4593bfd + path: generated/artifacts/civil-event--lookup-verify-registration--access-profile-registrar-verification.processing.json + sha256: sha256:21021162d3ede3099de37e1a142d35751fef060505ca482b5eae4657b2970e4c visibility: operation-bound - - id: civil-event--lookup-verify-registration--representation-registrar-verification-schema + - accessProfileIdentifier: registrar-verification + id: civil-event--lookup-verify-registration--access-profile-registrar-verification-schema mediaType: application/schema+json operationIdentifier: civil-event.lookup.verify-registration - path: generated/artifacts/civil-event--lookup-verify-registration--representation-registrar-verification.schema.json - representationIdentifier: registrar-verification - sha256: sha256:9bfbfca6752f1cc81419c49bc2b2ccc9c23ce14a044500b6a6d071d409052bed + path: generated/artifacts/civil-event--lookup-verify-registration--access-profile-registrar-verification.schema.json + sha256: sha256:02d8aaf26d8f5f26522afdea355080238b3f0a4e5492e84937227212f1914d71 visibility: operation-bound - - id: civil-event--lookup-verify-registration--representation-registrar-verification-shacl + - accessProfileIdentifier: registrar-verification + id: civil-event--lookup-verify-registration--access-profile-registrar-verification-shacl mediaType: text/turtle operationIdentifier: civil-event.lookup.verify-registration - path: generated/artifacts/civil-event--lookup-verify-registration--representation-registrar-verification.shacl.ttl - representationIdentifier: registrar-verification + path: generated/artifacts/civil-event--lookup-verify-registration--access-profile-registrar-verification.shacl.ttl sha256: sha256:bd2e2326bc3e25c614dc239aa5bb56ee371f57b6155d8ced40ea2eccfcdaaf7a visibility: operation-bound - - id: civil-event--lookup-verify-registration--representation-registrar-verification-vocabulary + - accessProfileIdentifier: registrar-verification + id: civil-event--lookup-verify-registration--access-profile-registrar-verification-vocabulary mediaType: application/ld+json operationIdentifier: civil-event.lookup.verify-registration - path: generated/artifacts/civil-event--lookup-verify-registration--representation-registrar-verification.vocabulary.jsonld - representationIdentifier: registrar-verification + path: generated/artifacts/civil-event--lookup-verify-registration--access-profile-registrar-verification.vocabulary.jsonld sha256: sha256:fdcf02c1ff87421d65b707e8dd0de30432d2650b9c53914e55002218d4da1cb1 visibility: operation-bound - - id: civil-event--lookup-verify-registration--representation-supervisory-capability + - accessProfileIdentifier: supervisory + id: civil-event--lookup-verify-registration--access-profile-supervisory-capability mediaType: application/json operationIdentifier: civil-event.lookup.verify-registration - path: generated/artifacts/civil-event--lookup-verify-registration--representation-supervisory.capability.json - representationIdentifier: supervisory - sha256: sha256:29dac05459854acd9d4c3c72a136422a83c9b36e7822824970d80580cc42bb9c + path: generated/artifacts/civil-event--lookup-verify-registration--access-profile-supervisory.capability.json + sha256: sha256:7041d09be104c948766f0d6a699fa576de67e0f2465b00ea38aa4e86f3dd7ed1 visibility: operation-bound - - id: civil-event--lookup-verify-registration--representation-supervisory-classifications + - accessProfileIdentifier: null + id: civil-event--lookup-verify-registration--access-profile-supervisory-classifications mediaType: application/json operationIdentifier: null - path: generated/artifacts/civil-event--lookup-verify-registration--representation-supervisory.classifications.json - representationIdentifier: null - sha256: sha256:4b4c5f7358022430baf7796849f42a89271d8a36273ef59526f3c12116820ba4 + path: generated/artifacts/civil-event--lookup-verify-registration--access-profile-supervisory.classifications.json + sha256: sha256:89cfc3c62a0c1181618fb840c104ac864d006722161a7ff4b8a9af72ba3d3565 visibility: operator-only - - id: civil-event--lookup-verify-registration--representation-supervisory-context + - accessProfileIdentifier: supervisory + id: civil-event--lookup-verify-registration--access-profile-supervisory-context mediaType: application/ld+json operationIdentifier: civil-event.lookup.verify-registration - path: generated/artifacts/civil-event--lookup-verify-registration--representation-supervisory.context.jsonld - representationIdentifier: supervisory + path: generated/artifacts/civil-event--lookup-verify-registration--access-profile-supervisory.context.jsonld sha256: sha256:e4408efdb0ddfed828dc8148f36f86c045ec0f558766ec5275908435dd92c689 visibility: operation-bound - - id: civil-event--lookup-verify-registration--representation-supervisory-processing + - accessProfileIdentifier: supervisory + id: civil-event--lookup-verify-registration--access-profile-supervisory-processing mediaType: application/json operationIdentifier: civil-event.lookup.verify-registration - path: generated/artifacts/civil-event--lookup-verify-registration--representation-supervisory.processing.json - representationIdentifier: supervisory - sha256: sha256:21c4c9c4d72e9f110247029329be643e18bb96e6aaaac5eb99b6d762b1721b4c + path: generated/artifacts/civil-event--lookup-verify-registration--access-profile-supervisory.processing.json + sha256: sha256:b0f5dcd7b36e3a39c585a7e31ad16327332abb7c89ff4f982248cbfbeb490633 visibility: operation-bound - - id: civil-event--lookup-verify-registration--representation-supervisory-schema + - accessProfileIdentifier: supervisory + id: civil-event--lookup-verify-registration--access-profile-supervisory-schema mediaType: application/schema+json operationIdentifier: civil-event.lookup.verify-registration - path: generated/artifacts/civil-event--lookup-verify-registration--representation-supervisory.schema.json - representationIdentifier: supervisory - sha256: sha256:77cc2dab5ac1334a9bddedd486eb669925fe7064c7ab068622d6204b1992338c + path: generated/artifacts/civil-event--lookup-verify-registration--access-profile-supervisory.schema.json + sha256: sha256:aae0ca59f36bd97aadf536c755d20175a41578b8646482a55f2aa43b1b67acc2 visibility: operation-bound - - id: civil-event--lookup-verify-registration--representation-supervisory-shacl + - accessProfileIdentifier: supervisory + id: civil-event--lookup-verify-registration--access-profile-supervisory-shacl mediaType: text/turtle operationIdentifier: civil-event.lookup.verify-registration - path: generated/artifacts/civil-event--lookup-verify-registration--representation-supervisory.shacl.ttl - representationIdentifier: supervisory + path: generated/artifacts/civil-event--lookup-verify-registration--access-profile-supervisory.shacl.ttl sha256: sha256:193cdd4cc378c7252c0c734354ef5e9f8f5eea4eafc5a8dcf9729d0a48e69a7a visibility: operation-bound - - id: civil-event--lookup-verify-registration--representation-supervisory-vocabulary + - accessProfileIdentifier: supervisory + id: civil-event--lookup-verify-registration--access-profile-supervisory-vocabulary mediaType: application/ld+json operationIdentifier: civil-event.lookup.verify-registration - path: generated/artifacts/civil-event--lookup-verify-registration--representation-supervisory.vocabulary.jsonld - representationIdentifier: supervisory + path: generated/artifacts/civil-event--lookup-verify-registration--access-profile-supervisory.vocabulary.jsonld sha256: sha256:f69da73736b4c0847cb66bb1524fb8f6182b7ff71c81eecc73f3588778d172d7 visibility: operation-bound - - id: civil-event--read--representation-registrar-capability + - accessProfileIdentifier: registrar + id: civil-event--read--access-profile-registrar-capability mediaType: application/json operationIdentifier: civil-event.read - path: generated/artifacts/civil-event--read--representation-registrar.capability.json - representationIdentifier: registrar - sha256: sha256:611eb052d2cbc4b5a114b437fe56878b9d4315eb9e931048845d2cc0b6a6cb05 + path: generated/artifacts/civil-event--read--access-profile-registrar.capability.json + sha256: sha256:08bb55f264fa71075706b40c00ebb622c83a6822f4a23e991c95093a7ef1ca30 visibility: operation-bound - - id: civil-event--read--representation-registrar-classifications + - accessProfileIdentifier: null + id: civil-event--read--access-profile-registrar-classifications mediaType: application/json operationIdentifier: null - path: generated/artifacts/civil-event--read--representation-registrar.classifications.json - representationIdentifier: null - sha256: sha256:eb5e6ecb0227899b83bb8cf094ec066cf0e87f580bc49ed45e47208dbf5b51ac + path: generated/artifacts/civil-event--read--access-profile-registrar.classifications.json + sha256: sha256:8dd41e9146bcb6972dd66ba897c35df25472584ac278dadea283a3e4006c055a visibility: operator-only - - id: civil-event--read--representation-registrar-context + - accessProfileIdentifier: registrar + id: civil-event--read--access-profile-registrar-context mediaType: application/ld+json operationIdentifier: civil-event.read - path: generated/artifacts/civil-event--read--representation-registrar.context.jsonld - representationIdentifier: registrar + path: generated/artifacts/civil-event--read--access-profile-registrar.context.jsonld sha256: sha256:44bc76f5795bbf1fc53b33373b901a5ce1bf612f459a07715db6dd71ae1f2d5d visibility: operation-bound - - id: civil-event--read--representation-registrar-processing + - accessProfileIdentifier: registrar + id: civil-event--read--access-profile-registrar-processing mediaType: application/json operationIdentifier: civil-event.read - path: generated/artifacts/civil-event--read--representation-registrar.processing.json - representationIdentifier: registrar - sha256: sha256:c799a7c5e879200de29bad0766783f403ff31fb8aee429493668c66a49998c14 + path: generated/artifacts/civil-event--read--access-profile-registrar.processing.json + sha256: sha256:fdb6099a53ca0f2127a5d45e8084947829de9d95de7ab49a8e32ff4abf6bfe9c visibility: operation-bound - - id: civil-event--read--representation-registrar-schema + - accessProfileIdentifier: registrar + id: civil-event--read--access-profile-registrar-schema mediaType: application/schema+json operationIdentifier: civil-event.read - path: generated/artifacts/civil-event--read--representation-registrar.schema.json - representationIdentifier: registrar - sha256: sha256:bd423d35379df92607a77594e1befe474a62ba18c3860c3d1455ce1029f02558 + path: generated/artifacts/civil-event--read--access-profile-registrar.schema.json + sha256: sha256:b725779e58e861f93a09e8933b89a1608f82cc1de08809fbd28f0fdd34597009 visibility: operation-bound - - id: civil-event--read--representation-registrar-shacl + - accessProfileIdentifier: registrar + id: civil-event--read--access-profile-registrar-shacl mediaType: text/turtle operationIdentifier: civil-event.read - path: generated/artifacts/civil-event--read--representation-registrar.shacl.ttl - representationIdentifier: registrar + path: generated/artifacts/civil-event--read--access-profile-registrar.shacl.ttl sha256: sha256:33cb54f0a1a3a35a132e50a78a40b2c2f7dd5abaf0a7fd768e32fb4a1bb2f390 visibility: operation-bound - - id: civil-event--read--representation-registrar-vocabulary + - accessProfileIdentifier: registrar + id: civil-event--read--access-profile-registrar-vocabulary mediaType: application/ld+json operationIdentifier: civil-event.read - path: generated/artifacts/civil-event--read--representation-registrar.vocabulary.jsonld - representationIdentifier: registrar + path: generated/artifacts/civil-event--read--access-profile-registrar.vocabulary.jsonld sha256: sha256:6a8225b7efed28ae336c11cbeec58bc94eaf89dcd18d2a097bc76c470f33ab85 visibility: operation-bound - - id: civil-event-classification + - accessProfileIdentifier: null + id: civil-event-classification mediaType: application/json operationIdentifier: null path: generated/artifacts/civil-event.classifications.json - representationIdentifier: null sha256: sha256:f5bbe318289cea6113fcf05873855ca972c0e0a1796d66f2b9ec28265cdaa25d visibility: operator-only - - id: civil-event-codelist-0 + - accessProfileIdentifier: null + id: civil-event-codelist-0 mediaType: application/schema+json operationIdentifier: null path: generated/artifacts/civil-event.codelist-0.schema.json - representationIdentifier: null sha256: sha256:cbd45c06b830956e657b9e930bdd9479278f42061c7333dd5694a57a5b2a0c73 visibility: operator-only - - id: civil-event-codelist-1 + - accessProfileIdentifier: null + id: civil-event-codelist-1 mediaType: application/schema+json operationIdentifier: null path: generated/artifacts/civil-event.codelist-1.schema.json - representationIdentifier: null sha256: sha256:e42dfbcab45a66032d126e0f203523ae44a6bc034278f2ce222f96f1ff0a78f0 visibility: operator-only - - id: civil-event-codelist-2 + - accessProfileIdentifier: null + id: civil-event-codelist-2 mediaType: application/schema+json operationIdentifier: null path: generated/artifacts/civil-event.codelist-2.schema.json - representationIdentifier: null sha256: sha256:c770e1867500e4c771718f0d412bc92be30d138fda628a85cff787f67ec9db09 visibility: operator-only - - id: civil-event-codelist-3 + - accessProfileIdentifier: null + id: civil-event-codelist-3 mediaType: application/schema+json operationIdentifier: null path: generated/artifacts/civil-event.codelist-3.schema.json - representationIdentifier: null sha256: sha256:3dd13f1498de4f4b16597ae4285412ef9e4b9859da058e45168a7de2e2252655 visibility: operator-only - - id: civil-event-full-schema + - accessProfileIdentifier: null + id: civil-event-full-schema mediaType: application/schema+json operationIdentifier: null path: generated/artifacts/civil-event.full.schema.json - representationIdentifier: null sha256: sha256:8719ed7bf8ccb512b1da1a2ed33e70308a4f2e75d919d3b62d071ba6e76a8bbb visibility: operator-only - - id: civil-event-full-shacl + - accessProfileIdentifier: null + id: civil-event-full-shacl mediaType: text/turtle operationIdentifier: null path: generated/artifacts/civil-event.full.shacl.ttl - representationIdentifier: null sha256: sha256:fbb6bb7991d85d5de37e5d4115318c43dc108a1a61f3449496a635221a435642 visibility: operator-only - - id: civil-event-full-vocabulary + - accessProfileIdentifier: null + id: civil-event-full-vocabulary mediaType: application/ld+json operationIdentifier: null path: generated/artifacts/civil-event.full.vocabulary.jsonld - representationIdentifier: null sha256: sha256:437d021d8cd85c4e7847dc9df983c7375a8332efb0b5ac77ce7a5fda4fda3b58 visibility: operator-only - - id: civil-event-processing-full + - accessProfileIdentifier: null + id: civil-event-processing-full mediaType: application/json operationIdentifier: null path: generated/artifacts/civil-event.processing.full.json - representationIdentifier: null sha256: sha256:762086646e734b6a8248a6bb62675490edc9a303559dca7e08719925656fec40 visibility: operator-only - - id: openapi-full + - accessProfileIdentifier: null + id: openapi-full mediaType: application/yaml operationIdentifier: null path: generated/openapi.full.yaml - representationIdentifier: null - sha256: sha256:96a8a6192ea325eb97a71724272413430acd28fa9e9e54c311360eb4cc735cdf + sha256: sha256:62ed8cad2048751ccf9a3dc1141c6352a243e70e990f8d16b85b17fb4e742c60 visibility: operator-only - - id: openapi-public + - accessProfileIdentifier: null + id: openapi-public mediaType: application/json operationIdentifier: null path: generated/openapi.public.json - representationIdentifier: null sha256: sha256:2dc557335daf6824d9a037998ef20fa1efe835c877fb46feec1e3fd2d19ac392 visibility: public governedFiles: @@ -1023,13 +1191,13 @@ projects: - generated: false mediaType: application/yaml path: governed/governance/classification-review-rationale.md - sha256: sha256:bbd8dbc65fb78549df5f425d54f073182d2a7a977f4fafbf10d7ace0dde7f514 + sha256: sha256:58606b2d7a0c69145ca9f1e951f2851701b75b0753afa86801f89acf51a20685 size: 258 visibility: operator-only - generated: false mediaType: application/yaml path: governed/governance/classification-review.yaml - sha256: sha256:9ccdc02c3564c793540c21dc1a86d50a03ed890c83f54ec4d6ed1f0e06855864 + sha256: sha256:11c7200188e146f82dc2e31986b0edcd476c619eaab6616a35114770cfcbee20 size: 423 visibility: operator-only - generated: false @@ -1053,6 +1221,6 @@ projects: - generated: false mediaType: application/yaml path: registry.yaml - sha256: sha256:6c6298aec06bb136d5851fc66fc0aa4d77bc7cf17e9ace2bf1bed35c6b387d4a - size: 8690 + sha256: sha256:8954bcc3af8c5178f8539d3f43b299a89c421d8a630d901a8d11e0882ad0328a + size: 8686 visibility: operator-only diff --git a/products/relay-v2/contracts/security-invariant-matrix.yaml b/products/relay-v2/contracts/security-invariant-matrix.yaml index cba46dfaf..4ce615888 100644 --- a/products/relay-v2/contracts/security-invariant-matrix.yaml +++ b/products/relay-v2/contracts/security-invariant-matrix.yaml @@ -64,6 +64,7 @@ invariants: negativeTest: insufficient_scope_and_unknown_data_surfaces_are_indistinguishable tests: - {path: crates/registry-relay-v2/tests/acceptance_http.rs, name: invalid_bearer_on_unknown_data_routes_is_audited_fail_closed} + - {path: crates/registry-relay-v2/tests/acceptance_http.rs, name: invalid_bearer_precedes_named_search_resolution} - {path: crates/registry-relay-v2/tests/acceptance_http.rs, name: insufficient_scope_and_unknown_data_surfaces_are_indistinguishable} - id: sec-operation-confinement threat: A token or request enables an operation the Registry did not compile. @@ -83,25 +84,25 @@ invariants: tests: - {path: crates/registry-relay-v2/src/compiler.rs, name: stale_review_fails_production_but_remains_an_authoring_finding} - {path: crates/registry-relay-v2/src/compiler.rs, name: starter_never_marks_classification_reviewed} - - id: sec-finite-representation-authorization - threat: A request selects an undeclared, malformed, or denied representation, crosses profiles with fields, or falls back to a different disclosure. - enforcementPoint: Closed compiled representation map, one exact default, pre-source selection, and selected-profile field validation. - negativeTest: representation_selection_authenticates_then_authorizes_the_exact_profile + - id: sec-finite-access-profile-authorization + threat: A request selects an undeclared, malformed, or denied access profile, crosses profiles with fields, or falls back to a different disclosure. + enforcementPoint: Closed compiled access-profile map, one exact default, pre-source selection, and selected-profile field validation. + negativeTest: access_profile_selection_authenticates_then_authorizes_the_exact_profile expected: An operation has one declared default and finite names; access and disclosure are evaluated only for the exact selection, unknown and scope-hidden names are indistinguishable as resource.not_found, and fields cannot cross the selected profile. - evidence: compiler-and-real-router-representation-tests + evidence: compiler-and-real-router-access-profile-tests tests: - - {path: crates/registry-relay-v2/src/compiler.rs, name: representation_default_and_transform_parameters_fail_closed} - - {path: crates/registry-relay-v2/tests/representation_http.rs, name: representation_selection_authenticates_then_authorizes_the_exact_profile} - - {path: crates/registry-relay-v2/tests/representation_http.rs, name: preflight_refusals_do_not_reach_source_and_attempt_audit_precedes_source_access} - - {path: crates/registry-relay-v2/tests/representation_http.rs, name: fields_only_minimize_the_selected_representation} - - id: sec-public-representation-processing-floor - threat: A public masked or minimized representation reads a confidential or restricted raw source column. - enforcementPoint: Per-representation processed-column closure and processing-handling compilation before route activation. - negativeTest: public_masked_representation_cannot_process_restricted_source + - {path: crates/registry-relay-v2/src/compiler.rs, name: access_profile_default_and_transform_parameters_fail_closed} + - {path: crates/registry-relay-v2/tests/access_profile_http.rs, name: access_profile_selection_authenticates_then_authorizes_the_exact_profile} + - {path: crates/registry-relay-v2/tests/access_profile_http.rs, name: preflight_refusals_do_not_reach_source_and_attempt_audit_precedes_source_access} + - {path: crates/registry-relay-v2/tests/access_profile_http.rs, name: fields_only_minimize_the_selected_access_profile} + - id: sec-public-access-profile-processing-floor + threat: A public masked or minimized access profile reads a confidential or restricted raw source column. + enforcementPoint: Per-access profile processed-column closure and processing-handling compilation before route activation. + negativeTest: public_masked_access_profile_cannot_process_restricted_source expected: Anonymous release requires public processing handling, including transform inputs and every selector, filter, order, and Registry Core source column. evidence: compiler-handling-tests tests: - - {path: crates/registry-relay-v2/src/compiler.rs, name: public_masked_representation_cannot_process_restricted_source} + - {path: crates/registry-relay-v2/src/compiler.rs, name: public_masked_access_profile_cannot_process_restricted_source} - {path: crates/registry-relay-v2/src/compiler.rs, name: public_operation_cannot_process_nonpublic_columns} - id: sec-closed-mask-and-date-transforms threat: Caller-directed or malformed transform input releases a raw, complete, noncanonical, wrongly typed, or over-precise value, or a transformed list filter or order key becomes an oracle over raw source values. @@ -115,17 +116,17 @@ invariants: - {path: crates/registry-relay-v2/src/transform.rs, name: date_precision_accepts_only_the_compiled_source_shape} - {path: crates/registry-relay-v2/src/compiler.rs, name: date_precision_is_typed_and_closed} - {path: crates/registry-relay-v2/src/compiler.rs, name: transformed_properties_cannot_be_list_filters_or_order_keys} - - {path: crates/registry-relay-v2/tests/representation_http.rs, name: transforms_are_bounded_value_free_and_terminal_audit_gates_exact_bytes} - - id: sec-representation-state-and-metadata-binding - threat: A cursor, ETag, metadata route, artifact, or quota crosses a representation boundary or reveals a protected profile. - enforcementPoint: Representation-bound cursor and cache identity, exact representation artifact gates, and operation-owned quota state. - negativeTest: cursor_and_etag_are_bound_to_selected_representation - expected: Cursor and ETag reuse across profiles fails; metadata and artifacts authorize one representation exactly; adding profiles does not multiply the operation quota. - evidence: real-router-representation-state-tests + - {path: crates/registry-relay-v2/tests/access_profile_http.rs, name: transforms_are_bounded_value_free_and_terminal_audit_gates_exact_bytes} + - id: sec-access-profile-state-and-metadata-binding + threat: A cursor, ETag, metadata route, artifact, or quota crosses an access-profile boundary or reveals a protected profile. + enforcementPoint: Access profile-bound cursor and cache identity, exact access-profile artifact gates, and operation-owned quota state. + negativeTest: cursor_and_etag_are_bound_to_selected_access_profile + expected: Cursor and ETag reuse across profiles fails; metadata and artifacts authorize one access profile exactly; adding profiles does not multiply the operation quota. + evidence: real-router-access-profile-state-tests tests: - - {path: crates/registry-relay-v2/tests/representation_http.rs, name: cursor_and_etag_are_bound_to_selected_representation} - - {path: crates/registry-relay-v2/tests/representation_http.rs, name: metadata_and_artifacts_authorize_each_representation_exactly} - - {path: crates/registry-relay-v2/tests/representation_http.rs, name: quotas_remain_operation_scoped_across_representations} + - {path: crates/registry-relay-v2/tests/access_profile_http.rs, name: cursor_and_etag_are_bound_to_selected_access_profile} + - {path: crates/registry-relay-v2/tests/access_profile_http.rs, name: metadata_and_artifacts_authorize_each_access_profile_exactly} + - {path: crates/registry-relay-v2/tests/access_profile_http.rs, name: quotas_remain_operation_scoped_across_access_profiles} - id: sec-operation-quota threat: One named lookup exhausts another resource or operation's request budget. enforcementPoint: Runtime quota configuration compiled into an operation-keyed limiter before readiness. @@ -170,7 +171,7 @@ invariants: tests: - {path: crates/registry-relay-v2/tests/acceptance_http.rs, name: malformed_disclosed_property_type_and_requiredness_fail_closed} - {path: crates/registry-relay-v2/tests/acceptance_http.rs, name: business_list_with_a_late_malformed_row_fails_atomically} - - {path: crates/registry-relay-v2/tests/representation_http.rs, name: transforms_are_bounded_value_free_and_terminal_audit_gates_exact_bytes} + - {path: crates/registry-relay-v2/tests/access_profile_http.rs, name: transforms_are_bounded_value_free_and_terminal_audit_gates_exact_bytes} - id: sec-reference-visibility threat: A Record leaks protected metadata or points its caller to an unresolvable schema or semantic model. enforcementPoint: Compile-time visibility closure across operation, resource, schema, semantic, capability, and OpenAPI artifacts. @@ -207,27 +208,29 @@ invariants: - {path: crates/registry-relay-v2/src/cursor.rs, name: cursor_conceals_order_values_and_refuses_tampering} - {path: crates/registry-relay-v2/src/cursor.rs, name: encrypting_the_same_cursor_twice_uses_distinct_nonces} - {path: crates/registry-relay-v2/src/cursor.rs, name: cursor_refuses_every_mismatched_request_binding_and_expiry} - - {path: crates/registry-relay-v2/src/cursor.rs, name: cursor_cannot_cross_spatial_or_representation_contexts} + - {path: crates/registry-relay-v2/src/cursor.rs, name: cursor_cannot_cross_spatial_or_format_contexts} - id: sec-spatial-disclosure-confinement - threat: GeoJSON negotiation, an unsafe coordinate row, or an unreviewed coordinate carrier widens disclosure beyond the selected governed representation. - enforcementPoint: Primary-geometry compilation, representation-scoped disclosure, complete Point validation before release, and one shared authorization and disclosure decision across JSON, JSON-LD, and GeoJSON. - negativeTest: geometry_disclosure_is_representation_scoped - expected: GeoJSON is available only when the exact selected representation discloses its classified Point; invalid coordinates fail closed before release and carrier columns never serialize. + threat: GeoJSON negotiation, an unsafe coordinate row, or an unreviewed coordinate carrier widens disclosure beyond the selected governed access profile. + enforcementPoint: Primary-geometry compilation, access profile-scoped disclosure, complete Point validation before release, and one shared authorization and disclosure decision across JSON, JSON-LD, and GeoJSON. + negativeTest: geometry_disclosure_is_access_profile_scoped + expected: GeoJSON is available only when the exact selected access profile discloses its classified Point; invalid coordinates fail closed before release and carrier columns never serialize. evidence: compiler-runtime-and-artifact-spatial-tests tests: - - {path: crates/registry-relay-v2/src/compiler.rs, name: geometry_disclosure_is_representation_scoped} + - {path: crates/registry-relay-v2/src/compiler.rs, name: geometry_disclosure_is_access_profile_scoped} - {path: crates/registry-relay-v2/src/artifacts.rs, name: spatial_artifacts_are_deterministic_bounded_and_carrier_free} - {path: crates/registry-relay-v2/tests/acceptance_http.rs, name: spatial_terminal_audit_failure_discards_held_feature_bytes} - id: sec-spatial-query-confinement threat: A caller turns a Point search into a broad geographic scan, antimeridian traversal, source expression, or alternate spatial service. enforcementPoint: Closed compiled bbox plan with finite CRS84 range, inclusive bounds, no antimeridian crossing, publisher span limits, and bound SQLite coordinates. - negativeTest: spatial_representations_validate_and_keep_distinct_cache_identities - expected: Only one declared Point bbox parameter can narrow an opted list; all other spatial query languages, CRS choices, geometry inputs, and crossing boxes are unavailable. + negativeTest: bbox_shape_refusals_are_audited_before_any_search_attempt + expected: Only one declared named Point-bbox search can execute its required bounded parameter; list rights cannot synthesize it, and all other spatial query languages, CRS choices, geometry inputs, and crossing boxes are unavailable. evidence: spatial-bbox-parser-and-sqlite-plan-tests tests: - {path: crates/registry-relay-v2/src/sqlite_runtime.rs, name: point_bbox_validation_is_numeric_and_crs84_bounded} - {path: crates/registry-relay-v2/src/sqlite_runtime.rs, name: point_bbox_refuses_dateline_crossing} - - {path: crates/registry-relay-v2/tests/acceptance_http.rs, name: spatial_representations_validate_and_keep_distinct_cache_identities} + - {path: crates/registry-relay-v2/tests/acceptance_http.rs, name: bbox_shape_refusals_are_audited_before_any_search_attempt} + - {path: crates/registry-relay-v2/tests/acceptance_http.rs, name: spatial_formats_validate_and_keep_distinct_cache_identities} + - {path: crates/registry-relay-v2/tests/acceptance_http.rs, name: all_three_registry_http_journeys_use_the_real_router} - id: sec-source-truthfulness threat: Relay serves incompatible schema, mixed live state, or false revision and cache claims. enforcementPoint: Schema fingerprint, one transaction per request, source-profile revisions, path identity checks, and per-execution snapshot digest verification before and after query execution. diff --git a/products/relay-v2/scripts/test_adopter_workflow.py b/products/relay-v2/scripts/test_adopter_workflow.py index 1b4f7333c..4560ecf35 100755 --- a/products/relay-v2/scripts/test_adopter_workflow.py +++ b/products/relay-v2/scripts/test_adopter_workflow.py @@ -109,22 +109,22 @@ def openapi_operations(document: dict[str, Any]) -> dict[tuple[str, str], dict[s return result -def representation_identifiers(operation: dict[str, Any], label: str) -> set[str]: - profiles = operation.get("x-registry-representations") +def access_profile_identifiers(operation: dict[str, Any], label: str) -> set[str]: + profiles = operation.get("x-registry-access-profiles") if not isinstance(profiles, list) or not profiles: - raise GateFailure(f"{label} has no finite representations") + raise GateFailure(f"{label} has no finite access profiles") identifiers: set[str] = set() for profile in profiles: if not isinstance(profile, dict) or not isinstance(profile.get("identifier"), str): - raise GateFailure(f"{label} has a malformed representation") + raise GateFailure(f"{label} has a malformed access profile") identifier = profile["identifier"] if not identifier or identifier in identifiers: - raise GateFailure(f"{label} has duplicate or empty representation identifiers") + raise GateFailure(f"{label} has duplicate or empty access-profile identifiers") identifiers.add(identifier) return identifiers -def public_representation_parameters(operation: dict[str, Any], label: str) -> set[str]: +def public_access_profile_parameters(operation: dict[str, Any], label: str) -> set[str]: parameters = operation.get("parameters") if not isinstance(parameters, list): raise GateFailure(f"{label} has no parameters") @@ -132,14 +132,14 @@ def public_representation_parameters(operation: dict[str, Any], label: str) -> s parameter for parameter in parameters if isinstance(parameter, dict) - and parameter.get("name") == "representation" + and parameter.get("name") == "accessProfile" and parameter.get("in") == "query" ] if len(matches) != 1: - raise GateFailure(f"{label} has no unique representation parameter") + raise GateFailure(f"{label} has no unique accessProfile parameter") identifiers = matches[0].get("schema", {}).get("enum") if not isinstance(identifiers, list) or not all(isinstance(item, str) for item in identifiers): - raise GateFailure(f"{label} has a malformed representation parameter") + raise GateFailure(f"{label} has a malformed accessProfile parameter") return set(identifiers) @@ -154,29 +154,29 @@ def validate_public_operation( ) -> None: if public.get("operationId") != full.get("operationId"): raise GateFailure("public OpenAPI operation identifier does not match full OpenAPI") - public_ids = representation_identifiers(public, "public OpenAPI operation") - full_ids = representation_identifiers(full, "full OpenAPI operation") + public_ids = access_profile_identifiers(public, "public OpenAPI operation") + full_ids = access_profile_identifiers(full, "full OpenAPI operation") if not public_ids.issubset(full_ids): - raise GateFailure("public OpenAPI representation is absent from full OpenAPI") - if public_representation_parameters(public, "public OpenAPI operation") != public_ids: - raise GateFailure("public OpenAPI representation parameter does not match public profiles") + raise GateFailure("public OpenAPI access profile is absent from full OpenAPI") + if public_access_profile_parameters(public, "public OpenAPI operation") != public_ids: + raise GateFailure("public OpenAPI accessProfile parameter does not match public profiles") if public.get("security") != [] or "x-registry-required-scopes" in public: raise GateFailure("public OpenAPI operation carries protected access or security") full_profiles = { profile["identifier"]: profile - for profile in full["x-registry-representations"] + for profile in full["x-registry-access-profiles"] } protected_ids = { - entry.get("representation") + entry.get("accessProfile") for entry in full.get("x-registry-required-scopes", []) - if isinstance(entry, dict) and isinstance(entry.get("representation"), str) + if isinstance(entry, dict) and isinstance(entry.get("accessProfile"), str) } - for profile in public["x-registry-representations"]: + for profile in public["x-registry-access-profiles"]: identifier = profile["identifier"] if identifier in protected_ids: - raise GateFailure("public OpenAPI exposes a protected representation") + raise GateFailure("public OpenAPI exposes a protected access profile") if profile != full_profiles[identifier]: - raise GateFailure("public OpenAPI representation differs from its full profile") + raise GateFailure("public OpenAPI access profile differs from its full profile") for reference_key in ( "schemaReference", "semanticModelReference", @@ -200,7 +200,7 @@ def validate_openapi(package: Path, artifacts: list[dict[str, Any]]) -> None: full_operation = full_operations.get(key) if full_operation is None: raise GateFailure("public OpenAPI path is absent from full OpenAPI") - if "x-registry-representations" in operation or "x-registry-representations" in full_operation: + if "x-registry-access-profiles" in operation or "x-registry-access-profiles" in full_operation: validate_public_operation(operation, full_operation, public_artifact_ids) elif operation != full_operation: raise GateFailure("public fixed OpenAPI operation differs from full OpenAPI") @@ -390,8 +390,24 @@ def accepted(arguments: list[str]) -> dict[str, Any]: materialize(project) shutil.copytree(project, previous) accepted(["inspect", str(project / "fixture.sqlite"), "--starters", str(root / "inspection")]) - check = accepted(["check", str(project), "--production"]) + check = accepted(["check", str(project), "--production", "--explain"]) accepted(["generate", str(project), "--output", str(root / "generated")]) + explanation = check["details"].get("operation_explanation") + if not isinstance(explanation, dict): + raise GateFailure(f"{project_name}: explained check omitted its operation explanation") + explanation_path = root / "generated/reports/operation-explanation.json" + if not explanation_path.is_file(): + raise GateFailure(f"{project_name}: generate omitted operation-explanation.json") + explanation_bytes = explanation_path.read_bytes() + canonical = json.dumps( + explanation, ensure_ascii=False, separators=(",", ":"), sort_keys=True + ).encode("utf-8") + if explanation_bytes != canonical: + raise GateFailure(f"{project_name}: operation explanation is not canonical JSON") + if json.loads(explanation_bytes) != explanation: + raise GateFailure(f"{project_name}: check and generate explanations disagree") + if (root / "generated/reports/representation-report.json").exists(): + raise GateFailure(f"{project_name}: legacy representation report was generated") accepted(["test", str(project)]) no_op_diff = accepted(["diff", str(previous), str(project)]) if no_op_diff.get("details", {}).get("report", {}).get("changes") != []: diff --git a/products/relay-v2/scripts/test_adopter_workflow_openapi.py b/products/relay-v2/scripts/test_adopter_workflow_openapi.py index 7fbef3db9..6f0cdf87d 100644 --- a/products/relay-v2/scripts/test_adopter_workflow_openapi.py +++ b/products/relay-v2/scripts/test_adopter_workflow_openapi.py @@ -18,7 +18,7 @@ class PublicOpenApiProjectionTests(unittest.TestCase): - def test_rejects_a_protected_representation_in_public_output(self) -> None: + def test_rejects_a_protected_access_profile_in_public_output(self) -> None: public_profile = { "identifier": "public-register", "default": True, @@ -44,9 +44,9 @@ def test_rejects_a_protected_representation_in_public_output(self) -> None: full = { "operationId": "business.read", "security": [{}, {"bearerAuth": []}], - "x-registry-representations": [public_profile, protected_profile], + "x-registry-access-profiles": [public_profile, protected_profile], "x-registry-required-scopes": [ - {"representation": "registrar", "scope": "registry:business:read-registrar"} + {"accessProfile": "registrar", "scope": "registry:business:read-registrar"} ], } public = { @@ -54,14 +54,14 @@ def test_rejects_a_protected_representation_in_public_output(self) -> None: "security": [], "parameters": [ { - "name": "representation", + "name": "accessProfile", "in": "query", "schema": {"enum": ["public-register", "registrar"]}, } ], - "x-registry-representations": [public_profile, copy.deepcopy(protected_profile)], + "x-registry-access-profiles": [public_profile, copy.deepcopy(protected_profile)], } - with self.assertRaisesRegex(WORKFLOW.GateFailure, "protected representation"): + with self.assertRaisesRegex(WORKFLOW.GateFailure, "protected access profile"): WORKFLOW.validate_public_operation( public, full, diff --git a/products/relay-v2/scripts/test_validate_product.py b/products/relay-v2/scripts/test_validate_product.py index f48cc61d5..21804f973 100644 --- a/products/relay-v2/scripts/test_validate_product.py +++ b/products/relay-v2/scripts/test_validate_product.py @@ -86,22 +86,22 @@ def load_without_excessive_size(path: Path): VALIDATOR.validate_catalogs(errors) self.assertTrue(any("invalid source-row classes must cover" in error for error in errors), errors) - def test_each_operation_requires_a_declared_default_representation(self) -> None: + def test_each_operation_requires_a_declared_default_access_profile(self) -> None: original = VALIDATOR.load_yaml def load_without_social_lookup_default(path: Path): value = copy.deepcopy(original(path)) if path.name == "registry.yaml" and path.parent.name == "social-assistance": value["resources"][0]["operations"]["lookups"][0].pop( - "defaultRepresentation" + "defaultAccessProfile" ) return value errors: list[str] = [] with mock.patch.object(VALIDATOR, "load_yaml", side_effect=load_without_social_lookup_default): - VALIDATOR.validate_acceptance_representation_contracts(errors) + VALIDATOR.validate_acceptance_access_profile_contracts(errors) self.assertTrue( - any("every declared operation needs one declared default representation" in error for error in errors), + any("every declared operation needs one declared default access profile" in error for error in errors), errors, ) @@ -116,7 +116,7 @@ def load_with_social_quota_drift(path: Path): errors: list[str] = [] with mock.patch.object(VALIDATOR, "load_yaml", side_effect=load_with_social_quota_drift): - VALIDATOR.validate_acceptance_representation_contracts(errors) + VALIDATOR.validate_acceptance_access_profile_contracts(errors) self.assertTrue( any("quota fixture must admit exactly" in error for error in errors), errors ) @@ -132,11 +132,41 @@ def load_with_civil_quota_drift(path: Path): errors: list[str] = [] with mock.patch.object(VALIDATOR, "load_yaml", side_effect=load_with_civil_quota_drift): - VALIDATOR.validate_acceptance_representation_contracts(errors) + VALIDATOR.validate_acceptance_access_profile_contracts(errors) self.assertTrue( any("lookup quota fixture must admit exactly" in error for error in errors), errors ) + def test_business_bbox_is_a_named_search_with_distinct_list_access(self) -> None: + original = VALIDATOR.load_yaml + + def load_with_bbox_on_list(path: Path): + value = copy.deepcopy(original(path)) + if path.name == "registry.yaml" and path.parent.name == "business-registry": + premises = next( + resource + for resource in value["resources"] + if resource["id"] == "registered-premises" + ) + premises["operations"]["list"]["spatialQuery"] = { + "bbox": { + "maximumLongitudeSpanDegrees": 2, + "maximumLatitudeSpanDegrees": 2, + } + } + premises["operations"]["searches"][0]["accessProfiles"][ + "registrar-premises" + ]["access"]["scope"] = "registry:business:premises-list" + return value + + errors: list[str] = [] + with mock.patch.object( + VALIDATOR, "load_yaml", side_effect=load_with_bbox_on_list + ): + VALIDATOR.validate_acceptance_access_profile_contracts(errors) + self.assertTrue(any("bbox must not be configured on list" in error for error in errors), errors) + self.assertTrue(any("list and search scopes must remain distinct" in error for error in errors), errors) + def test_invalid_source_row_scenario_must_match_the_executable_failure(self) -> None: original = VALIDATOR.load_yaml @@ -209,27 +239,27 @@ def load_without_civil_transform_scenario(path: Path): any("both bounded transforms require" in error for error in errors), errors ) - def test_unknown_and_scope_hidden_representations_share_one_outcome(self) -> None: + def test_unknown_and_scope_hidden_access_profiles_share_one_outcome(self) -> None: original = VALIDATOR.load_yaml - def load_with_enumerable_unknown_representation(path: Path): + def load_with_enumerable_unknown_access_profile(path: Path): value = copy.deepcopy(original(path)) if path.name == "expected-http.yaml" and path.parent.name == "social-assistance": step = next( item for item in value["steps"] - if item.get("id") == "unknown-representation" + if item.get("id") == "unknown-access-profile" ) - step["expect"]["code"] = "representation.not_found" + step["expect"]["code"] = "access_profile.not_found" return value errors: list[str] = [] with mock.patch.object( - VALIDATOR, "load_yaml", side_effect=load_with_enumerable_unknown_representation + VALIDATOR, "load_yaml", side_effect=load_with_enumerable_unknown_access_profile ): VALIDATOR.validate_catalogs(errors) self.assertTrue( - any("must conceal representation existence" in error for error in errors), errors + any("must conceal access-profile existence" in error for error in errors), errors ) def test_security_test_resolution_rejects_a_similar_prefix(self) -> None: diff --git a/products/relay-v2/scripts/validate_product.py b/products/relay-v2/scripts/validate_product.py index e9b0946fa..697d1a86a 100644 --- a/products/relay-v2/scripts/validate_product.py +++ b/products/relay-v2/scripts/validate_product.py @@ -33,10 +33,15 @@ "social-invalid-transform", "civil-invalid-transform", } -REPRESENTATION_CONCEALMENT_STEPS = { - "social-assistance": {"unauthorized-representation", "unknown-representation"}, - "business-registry": {"registrar-representation-denied", "public-representation-unknown"}, - "civil-event": {"supervisory-representation-denied", "invalid-representation"}, +ACCESS_PROFILE_CONCEALMENT_STEPS = { + "social-assistance": {"unauthorized-access-profile", "unknown-access-profile"}, + "business-registry": { + "registrar-access-profile-denied", + "public-access-profile-unknown", + "premises-search-access-profile-denied", + "premises-search-access-profile-unknown", + }, + "civil-event": {"supervisory-access-profile-denied", "invalid-access-profile"}, } SECURITY_INVARIANT_IDS = { "sec-contract-runtime-separation", @@ -48,10 +53,10 @@ "sec-resource-existence-concealment", "sec-operation-confinement", "sec-classification-review-binding", - "sec-finite-representation-authorization", - "sec-public-representation-processing-floor", + "sec-finite-access-profile-authorization", + "sec-public-access-profile-processing-floor", "sec-closed-mask-and-date-transforms", - "sec-representation-state-and-metadata-binding", + "sec-access-profile-state-and-metadata-binding", "sec-operation-quota", "sec-trusted-context", "sec-disclosure-monotonic", @@ -234,51 +239,89 @@ def validate_review_sidecar( errors.append(f"{project.name}: imported or manual review must not carry generated binding") -def validate_acceptance_representation_contracts(errors: list[str]) -> None: +def validate_acceptance_access_profile_contracts(errors: list[str]) -> None: expected_methods = { "social-assistance": "generated", "business-registry": "imported", "civil-event": "manual", } - expected_representations = { + expected_access_profiles = { "social-assistance": {"limited", "caseworker"}, - "business-registry": {"public-register", "registrar"}, + "business-registry": { + "public-register", + "registrar", + "public-premises", + "registrar-premises", + }, "civil-event": {"registrar", "supervisory"}, } for project_name in PROJECTS: project = PRODUCT_ROOT / "acceptance" / project_name registry = mapping(load_yaml(project / "registry.yaml"), f"{project_name} registry", errors) + if registry.get("apiVersion") != "relay.registrystack.org/v2alpha1": + errors.append(f"{project_name}: RegistryContract apiVersion is not frozen") validate_review_sidecar(project, registry, expected_methods[project_name], errors) resources = sequence(registry.get("resources"), f"{project_name} resources", errors) - operations = mapping(resources[0].get("operations") if resources else None, f"{project_name} operations", errors) - representations: set[str] = set() - operation_definitions = [operations.get("list"), operations.get("read")] + list( - operations.get("lookups", []) if isinstance(operations.get("lookups"), list) else [] - ) - for index, operation in enumerate(operation_definitions): - if operation is None: - continue - operation = mapping(operation, f"{project_name} operation[{index}]", errors) - profiles = mapping(operation.get("representations"), f"{project_name} operation[{index}] representations", errors) - default = operation.get("defaultRepresentation") - if not isinstance(default, str) or default not in profiles or not profiles: - errors.append(f"{project_name}: every declared operation needs one declared default representation") - for identifier, representation in profiles.items(): - representations.add(identifier) - representation = mapping(representation, f"{project_name} representation {identifier}", errors) - require_exact_keys( - representation, - {"access", "disclosureProfile"}, - f"{project_name} representation {identifier}", + access_profile_ids: set[str] = set() + primary_operations: dict[str, Any] = {} + for resource_index, raw_resource in enumerate(resources): + resource = mapping(raw_resource, f"{project_name} resource[{resource_index}]", errors) + operations = mapping( + resource.get("operations"), + f"{project_name} resource[{resource_index}] operations", + errors, + ) + if resource_index == 0: + primary_operations = operations + operation_definitions = [operations.get("list"), operations.get("read")] + operation_definitions.extend( + operations.get("lookups", []) + if isinstance(operations.get("lookups"), list) + else [] + ) + operation_definitions.extend( + operations.get("searches", []) + if isinstance(operations.get("searches"), list) + else [] + ) + for operation_index, operation in enumerate(operation_definitions): + if operation is None: + continue + operation = mapping( + operation, + f"{project_name} resource[{resource_index}] operation[{operation_index}]", errors, ) - if not expected_representations[project_name].issubset(representations): - errors.append(f"{project_name}: required acceptance representations are missing") + profiles = mapping( + operation.get("accessProfiles"), + f"{project_name} resource[{resource_index}] operation[{operation_index}] accessProfiles", + errors, + ) + default = operation.get("defaultAccessProfile") + if not isinstance(default, str) or default not in profiles or not profiles: + errors.append( + f"{project_name}: every declared operation needs one declared default access profile" + ) + for identifier, access_profile in profiles.items(): + access_profile_ids.add(identifier) + access_profile = mapping( + access_profile, + f"{project_name} access profile {identifier}", + errors, + ) + require_exact_keys( + access_profile, + {"access", "disclosureProfile"}, + f"{project_name} access profile {identifier}", + errors, + ) + if not expected_access_profiles[project_name].issubset(access_profile_ids): + errors.append(f"{project_name}: required acceptance access profiles are missing") if project_name == "social-assistance": properties = resources[0].get("properties", {}) if resources else {} transform = mapping(properties.get("maskedEnrolmentReference", {}).get("transform"), "social partial-string transform", errors) if transform != {"kind": "partial-string", "reveal": "suffix", "characters": 4}: - errors.append("social-assistance: limited representation must use the frozen partial-string transform") + errors.append("social-assistance: limited access profile must use the frozen partial-string transform") runtime = mapping( load_yaml(project / "runtime.yaml"), "social-assistance runtime", errors ) @@ -292,8 +335,8 @@ def validate_acceptance_representation_contracts(errors: list[str]) -> None: properties = resources[0].get("properties", {}) if resources else {} transform = mapping(properties.get("registrationYear", {}).get("transform"), "civil date-precision transform", errors) if transform != {"kind": "date-precision", "sourceType": "date", "precision": "year"}: - errors.append("civil-event: supervisory representation must use the frozen date-precision transform") - if operations.get("list") is not None: + errors.append("civil-event: supervisory access profile must use the frozen date-precision transform") + if primary_operations.get("list") is not None: errors.append("civil-event: collection list remains out of scope") runtime = mapping( load_yaml(project / "runtime.yaml"), "civil-event runtime", errors @@ -304,10 +347,92 @@ def validate_acceptance_representation_contracts(errors: list[str]) -> None: "civil-event: lookup quota fixture must admit exactly the eight " "pre-quota lookup executions before the named rate-limit proof" ) + if project_name == "business-registry": + premises = next( + ( + resource + for resource in resources + if isinstance(resource, dict) + and resource.get("id") == "registered-premises" + ), + None, + ) + premises = mapping(premises, "business-registry registered-premises", errors) + operations = mapping( + premises.get("operations"), + "business-registry registered-premises operations", + errors, + ) + list_operation = mapping( + operations.get("list"), + "business-registry registered-premises list", + errors, + ) + if "spatialQuery" in list_operation: + errors.append("business-registry: bbox must not be configured on list") + searches = sequence( + operations.get("searches"), + "business-registry registered-premises searches", + errors, + ) + search = next( + ( + item + for item in searches + if isinstance(item, dict) and item.get("id") == "within-bbox" + ), + None, + ) + search = mapping(search, "business-registry within-bbox search", errors) + expected_query = { + "kind": "point-bbox", + "maximumLongitudeSpanDegrees": 2, + "maximumLatitudeSpanDegrees": 2, + } + if search.get("query") != expected_query: + errors.append( + "business-registry: within-bbox must retain the frozen bounded Point query" + ) + search_profiles = mapping( + search.get("accessProfiles"), + "business-registry within-bbox access profiles", + errors, + ) + protected_search = mapping( + search_profiles.get("registrar-premises"), + "business-registry protected search access profile", + errors, + ) + list_profiles = mapping( + list_operation.get("accessProfiles"), + "business-registry premises list access profiles", + errors, + ) + protected_list = mapping( + list_profiles.get("registrar-premises"), + "business-registry protected list access profile", + errors, + ) + search_access = mapping( + protected_search.get("access"), + "business-registry protected search access", + errors, + ) + list_access = mapping( + protected_list.get("access"), + "business-registry protected list access", + errors, + ) + search_scope = search_access.get("scope") + list_scope = list_access.get("scope") + if not all(isinstance(scope, str) and scope for scope in (search_scope, list_scope)): + errors.append("business-registry: list and search require exact scopes") + elif search_scope == list_scope: + errors.append("business-registry: list and search scopes must remain distinct") def validate_catalogs(errors: list[str]) -> None: - validate_acceptance_representation_contracts(errors) + validate_acceptance_access_profile_contracts(errors) layout = mapping( load_yaml(PRODUCT_ROOT / "contracts/package-layout.yaml"), "package layout", errors ) @@ -377,8 +502,8 @@ def validate_catalogs(errors: list[str]) -> None: for required in { "openapi-full", "openapi-public", - "representation-schema", - "representation-shacl", + "access-profile-schema", + "access-profile-shacl", "full-record-schema", "full-record-shacl", "semantic-model", @@ -389,7 +514,7 @@ def validate_catalogs(errors: list[str]) -> None: "audit-event-schema", "identification-report", "classification-inventory", - "representation-report", + "operation-explanation", "contextual-review-findings", "classification-review", }: @@ -397,11 +522,11 @@ def validate_catalogs(errors: list[str]) -> None: errors.append(f"artifact inventory: missing {required}") steps = journey_steps(errors) - for project, concealed_steps in REPRESENTATION_CONCEALMENT_STEPS.items(): + for project, concealed_steps in ACCESS_PROFILE_CONCEALMENT_STEPS.items(): for step in concealed_steps: if steps.get(project, {}).get(step) != (404, "resource.not_found"): errors.append( - f"{project}: {step} must conceal representation existence as 404 resource.not_found" + f"{project}: {step} must conceal access-profile existence as 404 resource.not_found" ) scenarios = mapping( load_yaml(PRODUCT_ROOT / "contracts/acceptance-scenario-matrix.yaml"), From d99bc157fba6cca8996c071f4f7ceff1de3980e3 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 10 Aug 2026 17:26:58 +0700 Subject: [PATCH 19/24] docs(relay): explain access profiles and named search Signed-off-by: Jeremi Joslin --- .../site/src/content/docs/configure/relay.mdx | 111 ++++++++++++------ .../publish-governed-sqlite-registry.mdx | 75 +++++++----- 2 files changed, 116 insertions(+), 70 deletions(-) diff --git a/docs/site/src/content/docs/configure/relay.mdx b/docs/site/src/content/docs/configure/relay.mdx index 0ef240bca..7f802a6c5 100644 --- a/docs/site/src/content/docs/configure/relay.mdx +++ b/docs/site/src/content/docs/configure/relay.mdx @@ -1,6 +1,6 @@ --- title: Author a Registry Relay project -description: Turn reviewed SQLite views into a checked Registry contract, governed representations, and a sealed Relay package. +description: Turn reviewed SQLite views into a checked Registry contract, governed access profiles, and a sealed Relay package. status: draft owner: registry-docs source_repos: @@ -20,7 +20,7 @@ standards_referenced: Use `relayctl` to author one institution-owned Registry from reviewed SQLite views. The governed contract names the Registry, its mandatory Registry Core context, published properties, -finite representations, access, and disclosure. +finite access profiles, wire formats, query capabilities, and disclosure. The database remains a source binding, not an API model. ## When to use this @@ -36,8 +36,8 @@ A resource is a governed Record type in that Registry, not a SQLite table and no Prepare a non-writable SQLite inspection copy, narrow reviewed views, a stable Registry identifier, the Authority and operational roles, and synthetic fixtures. -Also agree which callers, purposes, row boundaries, fields, and representations each operation may -use. +Also agree which callers, purposes, row boundaries, fields, and query capabilities each operation +may use. Choose wire formats separately from those access decisions. Keep production Records, identifiers, credentials, and database files out of the project. ## Inspect structure before assigning meaning @@ -93,7 +93,7 @@ The command writes: - `reports/identification-report.json` - `reports/classification-inventory.json` -- `reports/representation-report.json` +- `reports/operation-explanation.json` - `reports/contextual-review-findings.json` - `governance/classification-review-starter.yaml` @@ -118,27 +118,30 @@ Production compilation refuses a missing, non-reviewed, stale, or digest-mismatc A relevant contract, schema, source-column, or classification change invalidates review. A rule-pack change matters only where that pack informed the review. -## Define reviewed representations +## Define reviewed access profiles -Each list, read, or named exact-lookup operation has a finite ordered map of named representations -and exactly one `defaultRepresentation`. -If an operation has any public representation, its default must also be public. This keeps omission +Each list, read, named exact-lookup, or named search operation has a finite ordered map of named +access profiles and exactly one `defaultAccessProfile`. +If an operation has any public access profile, its default must also be public. This keeps omission truthful for anonymous callers and the generated public OpenAPI. -Each representation selects one disclosure profile and one access rule. -Its profile defines the maximum property set that can reach `domainData`. +Each access profile selects one access rule and one disclosure profile. The disclosure profile +defines the maximum property set that can reach `domainData`. -Callers may omit `representation` to select the declared default, or supply one named -representation. -Relay authorizes the supplied representation exactly as requested. +Callers may omit `accessProfile` to select the declared default, or supply one named access +profile. Relay authorizes the supplied access profile exactly as requested. A syntactically valid unknown name and a scope-hidden name receive the same -`404 resource.not_found` response. An invalid bearer is `401`. None falls back to the default or -another representation. -The `fields` parameter can only select a non-empty subset of the selected representation's public +`404 resource.not_found` response. An invalid bearer is `401`. Relay never falls back to the +default or another access profile. +The `fields` parameter can only select a non-empty subset of the selected profile's disclosed properties. It cannot add a property, select a source column, change a transform, weaken handling, or bypass an access or row boundary. Registry Core remains present. +The `Accept` header selects JSON, JSON-LD, or GeoJSON serialization after access and disclosure +are decided. `formatProfile` refines a format where Relay exposes a finite profile choice. A wire +format is never an access right. + ## Add a bounded Point profile Declare one `primaryGeometry` when a resource publishes a reviewed longitude-latitude Point in @@ -157,41 +160,64 @@ primaryGeometry: classification: {privacy: non-personal, institutional: public, handling: public, status: reviewed} disclosureProfiles: public-premises: {properties: [premisesName, location]} + registrar-premises: {properties: [businessRegistrationNumber, premisesName, location]} operations: list: - defaultRepresentation: public-premises - representations: - public-premises: {access: public, disclosureProfile: public-premises} - allowUnfiltered: false - spatialQuery: - bbox: {maximumLongitudeSpanDegrees: 2, maximumLatitudeSpanDegrees: 2} + defaultAccessProfile: registrar-premises + accessProfiles: + registrar-premises: + access: {scope: registry:business:premises-list} + disclosureProfile: registrar-premises orderBy: [premisesIdentifier] pagination: {defaultPageSize: 50, maximumPageSize: 200} + searches: + - id: within-bbox + query: + kind: point-bbox + maximumLongitudeSpanDegrees: 2 + maximumLatitudeSpanDegrees: 2 + defaultAccessProfile: public-premises + accessProfiles: + public-premises: {access: public, disclosureProfile: public-premises} + registrar-premises: + access: {scope: registry:business:premises-search-registrar} + disclosureProfile: registrar-premises + orderBy: [premisesIdentifier] + pagination: {defaultPageSize: 50, maximumPageSize: 200} ``` -The representation map still owns access and maximum disclosure. JSON and JSON-LD are available -for every selected representation. GeoJSON becomes available only when that representation's -profile includes `location`. `Accept: application/geo+json` selects the wire format, while -`profile=rfc7946` or `profile=jsonfg` selects the GeoJSON profile. Neither grants another access -right or adds a property. - -`spatialQuery.bbox` enables one inclusive, bounded Point-containment query. The complete geometry -and both carrier columns must have effective `privacy: non-personal` classification. Relay refuses -non-finite values, coordinates outside CRS84, decreasing bounds, antimeridian crossing, and spans -larger than the authored limits. It does not expose a spatial expression language, reprojection, -joins, or dynamic SQLite extensions. The [business acceptance contract](https://github.com/registrystack/registry-stack/blob/d7b96d7d4aa89754c0f2ca6fe3d4d697e9e9e8ad/products/relay-v2/acceptance/business-registry/registry.yaml) +The named search makes the query shape and its authorization independently reviewable. A client +with only the list scope cannot search, and a client with only the protected search scope cannot +list. The request is explicit: + +```http +GET /v2/resources/registered-premises/searches/within-bbox?bbox=100,13,101,14 +Accept: application/geo+json +``` + +JSON and JSON-LD are available for every selected access profile. GeoJSON becomes available only +when that profile discloses `location`. `Accept: application/geo+json` selects the wire format, +while `formatProfile=rfc7946` or `formatProfile=jsonfg` selects its finite format profile. Neither +grants another access right or adds a property. + +The `point-bbox` query enables one inclusive, bounded Point-containment search. The complete +geometry and both carrier columns must have effective `privacy: non-personal` classification. +Relay refuses non-finite values, coordinates outside CRS84, decreasing bounds, antimeridian +crossing, and spans larger than the authored limits. It does not expose a spatial expression +language, reprojection, +joins, or dynamic SQLite extensions. The [business acceptance contract](https://github.com/registrystack/registry-stack/blob/main/products/relay-v2/acceptance/business-registry/registry.yaml) is the executable configuration example. ## Account for processing and disclosure Technical handling is ordered from `public` through `internal` and `confidential` to `restricted`. Processing handling is the most restrictive level across every column used to answer the request. -Disclosure handling is the most restrictive level among the properties a representation can +Disclosure handling is the most restrictive level among the properties an access profile can serialize. Compiler validity, audit context, cache eligibility, and source processing account for the -processing level, even where the released representation is less restrictive. +processing level, even where the released response is less restrictive. -A public representation cannot transform a non-public source column. +A public access profile cannot transform a non-public source column. Instead, bind it to a reviewed pre-derived public SQLite view column. Classifications can restrict an operation, but cannot create a route, scope, purpose, consent, lawful basis, or row authority. @@ -218,13 +244,20 @@ Run the production gate, generate reviewable artifacts, and replay synthetic HTT handoff: ```sh -relayctl check ./business-registry --production +relayctl check ./business-registry --production --explain relayctl generate ./business-registry relayctl test ./business-registry relayctl diff ./approved-business-registry ./business-registry relayctl package ./business-registry --output ./business-registry-package ``` +`check --production --explain` prints a deterministic, value-free operator view grouped by +resource, operation, query capability, access profile, processed columns, disclosure, transforms, +wire formats, and cache posture. It is derived from the same compiled Registry used by Relay. +`generate` writes the identical canonical report to +`generated/reports/operation-explanation.json` for review and diffing. A refused compilation does +not produce a partial explanation. + `check --production` and `package` refuse incomplete governance and unsafe package inputs. The package contains the contract's governed file closure and generated artifacts, but not SQLite data or fixtures. @@ -237,5 +270,5 @@ different interpretation. | --- | --- | --- | | `inspect` refuses the source | The inspection database is unsafe or writable | Create a non-writable physical-path copy and retry. | | Production checking reports a review error | The sidecar is missing, not reviewed, stale, or does not match the inventory | Complete the institutional review and regenerate the affected report. | -| A field is rejected | It is not in the selected representation | Review and change the governed representation, then repeat the full workflow. | +| A field is rejected | It is not in the selected access profile's disclosure profile | Review and change the governed access profile, then repeat the full workflow. | | Packaging refuses the destination | The output directory exists or its closure is unsafe | Select a new empty revisioned directory. | diff --git a/docs/site/src/content/docs/tutorials/publish-governed-sqlite-registry.mdx b/docs/site/src/content/docs/tutorials/publish-governed-sqlite-registry.mdx index 9c243b37b..f21e9d9a8 100644 --- a/docs/site/src/content/docs/tutorials/publish-governed-sqlite-registry.mdx +++ b/docs/site/src/content/docs/tutorials/publish-governed-sqlite-registry.mdx @@ -115,28 +115,34 @@ not copied into the package. Compile the production checks before generating or serving anything: ```sh -relayctl check "$project" --production +relayctl check "$project" --production --explain ``` -After the command label, the report begins with a successful status and no -diagnostics. This abridged excerpt omits the compiled configuration key paths -at `details.configuration_key_paths`: +The successful explanation is grouped by Registry resource and operation. Its exact digests follow +your checkout, but the shape includes these sections: -```json -{ - "status": "success", - "diagnostics": [], - "details": { - "kind": "check", - "contract_revision": "sha256:", - "production": true - } -} +```text +relayctl check +Registry: urn:example:registry:registered-businesses +Contract revision: sha256: + +Resource: registered-premises + + Operation: search:within-bbox GET /v2/resources/registered-premises/searches/within-bbox consultation/search + query capabilities: + point-bbox: available (point-bbox-search-operation); required + access profile: public-premises (default) + wire formats: + json: application/json + json-ld: application/ld+json + geojson: application/geo+json; format-profiles=rfc7946, jsonfg ``` This is the first governed result. Production checks refuse unreviewed semantic or classification suggestions, missing or stale classification-review -evidence, source-schema changes, and inconsistent access or disclosure rules. +evidence, source-schema changes, and inconsistent access or disclosure rules. The explanation +derives from the accepted compiled Registry and contains categorical reasons, not row, request, +principal, token, or bbox values. Generate the public and operator artifacts: @@ -191,8 +197,9 @@ Generated files include OpenAPI 3.1, JSON Schema, Shapes Constraint Language (SHACL), JSON for Linked Data (JSON-LD), codelist schemas, processing descriptions, capability discovery, and deterministic value-free reports under `generated/reports/` and `generated/governance/`. The reports distinguish -source-column processing handling from output-property disclosure handling. -They are review inputs, not automatic approvals. +source-column processing handling from output-property disclosure handling. The canonical +`generated/reports/operation-explanation.json` is the machine-readable form of the explanation +shown by `check --explain`. They are review inputs, not automatic approvals. ## Seal the deployment package @@ -240,10 +247,10 @@ The fixture expectation also requires complete Registry Core context and exactly domain properties. An unknown field, SQLite column, sort, or undeclared filter is a separate refusal case in the full journey and cannot change the compiled query. -The contract also defines a protected `registrar` representation for the same read and list -operations. The full fixture journey proves its distinct scope, no-store cache posture, a denied -request, and an unknown representation. The unknown and scope-hidden cases use the same generic -`404 resource.not_found` response. Relay authorizes the requested representation exactly and never +The contract also defines protected `registrar` access profiles for read and list operations. The +full fixture journey proves their distinct scopes, no-store cache posture, denied requests, and an +unknown access profile. The unknown and scope-hidden cases use the same generic +`404 resource.not_found` response. Relay authorizes the requested access profile exactly and never falls back to the public default. ## Verify the spatial response boundary @@ -266,14 +273,20 @@ The report contains one successful step: } ``` -The request uses `Accept: application/geo+json`, `profile=jsonfg`, and a bounded CRS84 `bbox`. -Those values select a wire format and a fixed query plan. The `public-premises` representation -still supplies the access rule and maximum disclosure profile. The full journey also proves RFC -7946 output, JSON and JSON-LD equivalence, field minimization to `geometry: null`, invalid bbox -refusal, cursor binding, nonspatial negotiation refusal, and value-free coordinate failure. +The request uses `Accept: application/geo+json`, `formatProfile=jsonfg`, and a bounded CRS84 +`bbox` on the named `within-bbox` search. `Accept` and `formatProfile` select serialization. The +named search fixes the query shape, while the `public-premises` access profile supplies the access +rule and maximum disclosure. The full journey also proves RFC 7946 output, JSON and JSON-LD +equivalence, field minimization to `geometry: null`, invalid bbox refusal, cursor binding, +nonspatial negotiation refusal, and value-free coordinate failure. + +The same resource deliberately gives list, read, and search independent access profiles. A client +with only the protected list scope cannot call the named search, and a client with only the +protected search scope cannot list. The bbox capability does not create another authorization +plane. Starting the packaged service requires the configured token issuer to be reachable because the -package contains protected representations, even when the request you plan to send is public. +package contains protected access profiles, even when the request you plan to send is public. Continue with [Operate Registry Relay](../../operate/relay/) to bind a real institutional issuer, trusted paths, audit retention, and a private listener. @@ -290,13 +303,13 @@ The cleanup commands print nothing when they succeed. ## What you built -- One reviewed contract produced the API package, semantic artifacts, disclosure rules, and - capability inventory. +- One reviewed contract produced the API package, semantic artifacts, access profiles, disclosure + rules, and capability inventory. - SQLite tables and columns did not become routes by convention. - Generated identification and classification material remained suggestions until the governed review accepted it. - The caller selected fewer properties but could not select more. -- The Point used the same operation access and disclosure boundary in JSON, +- The Point used the same access-profile and disclosure boundary in JSON, JSON-LD, RFC 7946 GeoJSON, and JSON-FG. - Mandatory Registry and Record context remained present in every result. - The shared Relay router verified the public, protected, and refusal behavior against synthetic @@ -319,4 +332,4 @@ The cleanup commands print nothing when they succeed. | --- | --- | --- | | `cargo build` cannot find a package | The command is outside the Registry Stack root or the checkout predates Relay | Return to the source root and confirm that `crates/registry-relay-v2` exists. | | Relay refuses a path containing `/tmp` | On macOS, `/tmp` is a symlink | Keep the `pwd -P` assignment so the runtime receives the physical path. | -| `relayctl check` returns diagnostics | A governed input or SQLite schema does not match the reviewed contract | Read the diagnostic identifiers, fix the authored input, and run `check --production` again. | +| `relayctl check` returns diagnostics | A governed input or SQLite schema does not match the reviewed contract | Read the diagnostic identifiers, fix the authored input, and run `check --production --explain` again. | From cbc6d4a2051ade891f44cf595a2b32fc90a38f46 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 10 Aug 2026 17:51:28 +0700 Subject: [PATCH 20/24] fix(relay): align governed search tooling Signed-off-by: Jeremi Joslin --- crates/registry-relay-v2/src/api.rs | 4 +- crates/registry-relay-v2/src/artifacts.rs | 62 ++-- crates/registry-relay-v2/src/fixtures.rs | 264 +++++++++++++++++- .../registry-relay-v2/src/identification.rs | 74 ++++- crates/registry-relay-v2/src/model.rs | 3 + crates/registry-relay-v2/src/tooling.rs | 64 +++++ products/relay-v2/CONFIGURATION-EXAMPLES.md | 125 +++------ .../contracts/generated-baselines.yaml | 12 +- 8 files changed, 481 insertions(+), 127 deletions(-) diff --git a/crates/registry-relay-v2/src/api.rs b/crates/registry-relay-v2/src/api.rs index 932706e0f..6b3defa93 100644 --- a/crates/registry-relay-v2/src/api.rs +++ b/crates/registry-relay-v2/src/api.rs @@ -30,7 +30,7 @@ use crate::format_capabilities::{ }; use crate::model::{ CompiledAccess, CompiledAccessProfile, CompiledOperation, CompiledResource, - ConsultationPattern, OperationKind, RowAuthoritySource, + ConsultationPattern, OperationKind, RowAuthoritySource, POINT_BBOX_PREDICATE, }; use crate::problem::{ProblemCode, TraceContext}; use crate::server::{uri_within_bound, RelayService}; @@ -3167,7 +3167,7 @@ fn capability( json!({ "bbox": { "crs": CRS84_URI, - "predicate": "exact-point-intersection", + "predicate": POINT_BBOX_PREDICATE, "maximumLongitudeSpanDegrees": spatial.maximum_longitude_span_degrees, "maximumLatitudeSpanDegrees": spatial.maximum_latitude_span_degrees, } diff --git a/crates/registry-relay-v2/src/artifacts.rs b/crates/registry-relay-v2/src/artifacts.rs index cc047ac6e..5320fe480 100644 --- a/crates/registry-relay-v2/src/artifacts.rs +++ b/crates/registry-relay-v2/src/artifacts.rs @@ -16,7 +16,7 @@ use crate::format_capabilities::{ }; use crate::model::{ CompiledAccess, CompiledOperation, CompiledRegistry, CompiledResource, ConsultationPattern, - OperationKind, + OperationKind, POINT_BBOX_PREDICATE, }; use crate::semantics::{ access_profile_schema, access_profile_shacl, full_record_schema, full_record_shacl, @@ -717,7 +717,7 @@ fn openapi(registry: &CompiledRegistry, public_only: bool) -> Value { parameters.push(json!({ "name": "bbox", "in": "query", - "required": true, + "required": false, "style": "form", "explode": false, "schema": { @@ -726,8 +726,8 @@ fn openapi(registry: &CompiledRegistry, public_only: bool) -> Value { "minItems": 4, "maxItems": 4 }, - "description": "Inclusive CRS84 point bounds: west,south,east,north", - "x-registry-spatial-predicate": "exact-point-intersection", + "description": "Required for a fresh search and omitted for cursor continuation. Inclusive CRS84 point bounds: west,south,east,north.", + "x-registry-spatial-predicate": POINT_BBOX_PREDICATE, "x-registry-crs": CRS84_URI, "x-registry-maximum-longitude-span-degrees": bbox.maximum_longitude_span_degrees, "x-registry-maximum-latitude-span-degrees": bbox.maximum_latitude_span_degrees, @@ -786,6 +786,15 @@ fn openapi(registry: &CompiledRegistry, public_only: bool) -> Value { "default": {"$ref": "#/components/responses/Problem"} } }); + if matches!(&operation.kind, OperationKind::Search { .. }) { + operation_value + .as_object_mut() + .expect("operation object") + .insert( + "description".into(), + json!("Start this named search with bbox, no cursor, and any other documented optional query parameters. Continue it with cursor and optional accessProfile only; all other query parameters, including bbox, are invalid with cursor."), + ); + } let required_scopes = visible_access_profiles .iter() .filter_map(|access_profile| match &access_profile.access { @@ -1228,7 +1237,7 @@ fn capability_inventory( "spatialQuery": operation.query.spatial_bbox.as_ref().map(|spatial| json!({ "bbox": { "crs": CRS84_URI, - "predicate": "exact-point-intersection", + "predicate": POINT_BBOX_PREDICATE, "maximumLongitudeSpanDegrees": spatial.maximum_longitude_span_degrees, "maximumLatitudeSpanDegrees": spatial.maximum_latitude_span_degrees, } @@ -1594,23 +1603,40 @@ mod tests { assert!(operation["responses"]["200"]["content"] .get("application/geo+json") .is_some()); - let bbox = operation["parameters"] - .as_array() - .expect("parameters") + let parameters = operation["parameters"].as_array().expect("parameters"); + let bbox = parameters .iter() .find(|parameter| parameter["name"] == "bbox") .expect("bbox parameter"); assert_eq!(bbox["schema"]["minItems"], 4); assert_eq!(bbox["explode"], false); - assert_eq!(bbox["required"], true); - assert!(operation["parameters"] - .as_array() - .expect("parameters") + assert_eq!(bbox["required"], false); + assert_eq!( + bbox["description"], + "Required for a fresh search and omitted for cursor continuation. Inclusive CRS84 point bounds: west,south,east,north." + ); + assert_eq!( + operation["description"], + "Start this named search with bbox, no cursor, and any other documented optional query parameters. Continue it with cursor and optional accessProfile only; all other query parameters, including bbox, are invalid with cursor." + ); + let required_query_parameters = parameters + .iter() + .filter(|parameter| parameter["in"] == "query" && parameter["required"] == true) + .filter_map(|parameter| parameter["name"].as_str()) + .collect::>(); + assert!( + required_query_parameters.is_empty(), + "cursor-only continuation must not be blocked by required query parameters: {required_query_parameters:?}" + ); + let cursor = parameters + .iter() + .find(|parameter| parameter["name"] == "cursor") + .expect("cursor parameter"); + assert_eq!(cursor["required"], false); + assert!(parameters .iter() .any(|parameter| parameter["name"] == "accessProfile")); - assert!(operation["parameters"] - .as_array() - .expect("parameters") + assert!(parameters .iter() .any(|parameter| parameter["name"] == "formatProfile")); let expected_formats = @@ -1631,7 +1657,7 @@ mod tests { capability_document["capabilities"][0]["wireFormats"], expected_formats ); - assert!(encoded.contains("exact-point-intersection")); + assert!(encoded.contains(POINT_BBOX_PREDICATE)); assert!(encoded.contains(JSON_FG_PROFILE_URI)); assert!(!encoded.contains("longitude_col")); assert!(!encoded.contains("latitude_col")); @@ -1665,9 +1691,9 @@ mod tests { ) .expect("UTF-8 capabilities"); assert!(!public_openapi.contains("application/geo+json")); - assert!(!public_openapi.contains("exact-point-intersection")); + assert!(!public_openapi.contains(POINT_BBOX_PREDICATE)); assert!(!public_capabilities.contains("application/geo+json")); - assert!(!public_capabilities.contains("exact-point-intersection")); + assert!(!public_capabilities.contains(POINT_BBOX_PREDICATE)); assert!(!public_openapi.contains("/searches/within-bbox")); assert!(!public_capabilities.contains("record.search.within-bbox")); diff --git a/crates/registry-relay-v2/src/fixtures.rs b/crates/registry-relay-v2/src/fixtures.rs index 10ff9096c..49f54ea39 100644 --- a/crates/registry-relay-v2/src/fixtures.rs +++ b/crates/registry-relay-v2/src/fixtures.rs @@ -87,7 +87,6 @@ pub fn compile_fixture_plan( validate_authorizations(journey, &mut diagnostics); let mut ids = BTreeSet::new(); - let mut steps = Vec::new(); for (index, step) in journey.steps.iter().enumerate() { if !ids.insert(step.id.as_str()) { diagnostic( @@ -97,6 +96,12 @@ pub fn compile_fixture_plan( "fixture step identifiers must be unique", ); } + } + let dependencies = fixture_dependencies(journey, &mut diagnostics); + let selected_steps = selected_step_closure(journey, &dependencies, selected_fixture); + + let mut steps = Vec::new(); + for (index, step) in journey.steps.iter().enumerate() { if let Some(reference) = step.authorization_fixture.as_deref() { if !journey.authorizations.contains_key(reference) { diagnostic( @@ -107,7 +112,7 @@ pub fn compile_fixture_plan( ); } } - if selected_fixture.is_some_and(|selected| selected != step.id) { + if !selected_steps.contains(&index) { continue; } if !step.request.path.starts_with('/') || step.request.path.contains(['?', '#']) { @@ -193,6 +198,164 @@ pub fn compile_fixture_plan( } } +#[derive(Clone, Debug)] +struct FixtureDependency { + target: String, + location: String, +} + +fn fixture_dependencies( + journey: &FixtureJourney, + diagnostics: &mut Vec, +) -> Vec> { + let identifiers = + journey + .steps + .iter() + .enumerate() + .fold(BTreeMap::new(), |mut identifiers, (index, step)| { + identifiers.entry(step.id.as_str()).or_insert(index); + identifiers + }); + let mut equivalence_classes = BTreeMap::<&str, &str>::new(); + let references = journey + .steps + .iter() + .enumerate() + .map(|(index, step)| { + let mut references = step_dependencies(step, index); + if let Some(class) = step.expect.equivalence_class.as_deref() { + if let Some(target) = equivalence_classes.get(class) { + references.push(FixtureDependency { + target: (*target).into(), + location: format!("steps[{index}].expect.equivalenceClass"), + }); + } else { + equivalence_classes.insert(class, step.id.as_str()); + } + } + references + }) + .collect::>(); + let mut dependencies = vec![Vec::new(); journey.steps.len()]; + + for (index, step_references) in references.iter().enumerate() { + for reference in step_references { + let Some(target) = identifiers.get(reference.target.as_str()).copied() else { + diagnostic( + diagnostics, + "fixture.dependency_unknown", + &reference.location, + "the fixture step dependency is unknown", + ); + continue; + }; + if !dependencies[index].contains(&target) { + dependencies[index].push(target); + } + } + } + + for (index, step_references) in references.iter().enumerate() { + for reference in step_references { + let Some(target) = identifiers.get(reference.target.as_str()).copied() else { + continue; + }; + if dependency_reaches(&dependencies, target, index) { + diagnostic( + diagnostics, + "fixture.dependency_cycle", + &reference.location, + "fixture step dependencies must be acyclic", + ); + } else if target > index { + diagnostic( + diagnostics, + "fixture.dependency_forward", + &reference.location, + "fixture steps may depend only on preceding steps", + ); + } + } + } + dependencies +} + +fn step_dependencies(step: &FixtureStep, index: usize) -> Vec { + let mut dependencies = Vec::new(); + for (name, value) in &step.request.query { + if let Some(target) = value + .as_str() + .and_then(|value| value.strip_prefix("$nextCursor:")) + { + dependencies.push(FixtureDependency { + target: target.into(), + location: format!("steps[{index}].request.query.{name}"), + }); + } + } + for (name, value) in &step.request.headers { + if let Some(target) = value.strip_prefix("$etag:") { + dependencies.push(FixtureDependency { + target: target.into(), + location: format!("steps[{index}].request.headers.{name}"), + }); + } + } + if let Some(target) = step.expect.records_equivalent_to.as_ref() { + dependencies.push(FixtureDependency { + target: target.clone(), + location: format!("steps[{index}].expect.recordsEquivalentTo"), + }); + } + if let Some(target) = step.expect.etag_same_as.as_ref() { + dependencies.push(FixtureDependency { + target: target.clone(), + location: format!("steps[{index}].expect.etagSameAs"), + }); + } + dependencies +} + +fn dependency_reaches(dependencies: &[Vec], start: usize, target: usize) -> bool { + let mut pending = vec![start]; + let mut visited = BTreeSet::new(); + while let Some(index) = pending.pop() { + if index == target { + return true; + } + if visited.insert(index) { + pending.extend(dependencies[index].iter().copied()); + } + } + false +} + +fn selected_step_closure( + journey: &FixtureJourney, + dependencies: &[Vec], + selected_fixture: Option<&str>, +) -> BTreeSet { + let Some(selected_fixture) = selected_fixture else { + return (0..journey.steps.len()).collect(); + }; + let Some(selected) = journey + .steps + .iter() + .position(|step| step.id == selected_fixture) + else { + return BTreeSet::new(); + }; + let mut selected_steps = BTreeSet::new(); + let mut pending = vec![selected]; + while let Some(index) = pending.pop() { + if selected_steps.insert(index) { + pending.extend(dependencies[index].iter().copied()); + } + } + selected_steps +} + /// Execute each preflighted step against the real in-process Relay router. /// Response bytes are inspected in memory and never copied into the report. pub async fn execute_fixture_journey( @@ -1214,6 +1377,103 @@ steps: assert!(!token.contains("principal")); } + #[test] + fn selected_step_closure_includes_every_transitive_reference_and_nothing_else() { + let journey = parse_journey( + r#" +schemaVersion: relay.registrystack.org/http-journey/v1alpha1 +registry: urn:example:registry +authorizations: {} +steps: + - id: cursor-source + request: {method: GET, path: /health} + expect: {status: 200} + - id: cursor-consumer + request: + method: GET + path: /health + query: {cursor: "$nextCursor:cursor-source"} + expect: {status: 200} + - id: etag-consumer + request: + method: GET + path: /health + headers: {if-none-match: "$etag:cursor-consumer"} + expect: {status: 200} + - id: format-consumer + request: {method: GET, path: /health} + expect: + status: 200 + recordsEquivalentTo: etag-consumer + etagSameAs: etag-consumer + equivalenceClass: selected-equivalence + - id: selected + request: {method: GET, path: /health} + expect: {status: 200, equivalenceClass: selected-equivalence} + - id: unrelated + request: {method: GET, path: /health} + expect: {status: 200} +"#, + ) + .expect("fixture parses"); + let mut diagnostics = Vec::new(); + let dependencies = fixture_dependencies(&journey, &mut diagnostics); + assert!(diagnostics.is_empty(), "{diagnostics:?}"); + assert_eq!( + selected_step_closure(&journey, &dependencies, Some("selected")), + BTreeSet::from([0, 1, 2, 3, 4]) + ); + } + + #[test] + fn unknown_forward_and_cyclic_step_dependencies_are_distinct_refusals() { + for (steps, expected) in [ + ( + r#" + - id: one + request: {method: GET, path: /health} + expect: {status: 200, recordsEquivalentTo: missing} +"#, + "fixture.dependency_unknown", + ), + ( + r#" + - id: one + request: {method: GET, path: /health} + expect: {status: 200, recordsEquivalentTo: two} + - id: two + request: {method: GET, path: /health} + expect: {status: 200} +"#, + "fixture.dependency_forward", + ), + ( + r#" + - id: one + request: {method: GET, path: /health} + expect: {status: 200, recordsEquivalentTo: two} + - id: two + request: {method: GET, path: /health} + expect: {status: 200, recordsEquivalentTo: one} +"#, + "fixture.dependency_cycle", + ), + ] { + let journey = parse_journey(&format!( + "schemaVersion: {JOURNEY_VERSION}\nregistry: urn:example:registry\nauthorizations: {{}}\nsteps:{steps}" + )) + .expect("fixture parses"); + let mut diagnostics = Vec::new(); + fixture_dependencies(&journey, &mut diagnostics); + assert!( + diagnostics + .iter() + .any(|diagnostic| diagnostic.code == expected), + "{diagnostics:?}" + ); + } + } + #[test] fn fixture_yaml_accepts_only_the_closed_geojson_expectations() { let yaml = r#" diff --git a/crates/registry-relay-v2/src/identification.rs b/crates/registry-relay-v2/src/identification.rs index 60b0ec58e..892dc05be 100644 --- a/crates/registry-relay-v2/src/identification.rs +++ b/crates/registry-relay-v2/src/identification.rs @@ -15,7 +15,7 @@ use thiserror::Error; use crate::contract::{ AccessRule, AuthorityRowBinding, ClassificationReviewDocument, GeneratedIdentificationBinding, - Handling, IdentificationMethod, RegistryContract, ReviewStatus, RulePackBinding, + Handling, IdentificationMethod, RegistryContract, ReviewStatus, RulePackBinding, SourceProfile, }; use crate::format_capabilities::{ response_format_capabilities, FormatProfileIdentifier, WireFormatCapability, @@ -25,7 +25,7 @@ use crate::model::{ CapabilityFamily, ColumnUse, CompiledAccess, CompiledAccessProfile, CompiledOperation, CompiledRegistry, CompiledResource, CompiledTransform, ConsultationPattern, EffectiveClassification, ObservedColumn, ObservedSourceSchema, OperationKind, - RowAuthoritySource, + RowAuthoritySource, POINT_BBOX_PREDICATE, }; pub const IDENTIFICATION_REPORT_PATH: &str = "reports/identification-report.json"; @@ -627,11 +627,7 @@ pub fn operation_explanation( transforms: transform_explanations(resource, access_profile), wire_formats: response_format_capabilities(resource, access_profile), cache: CacheExplanation { - kind: if matches!(access_profile.access, CompiledAccess::Public) { - CachePosture::PublicRevalidate - } else { - CachePosture::NoStore - }, + kind: cache_posture(registry, resource, access_profile), }, }) .collect::>(); @@ -672,6 +668,25 @@ pub fn operation_explanation( }) } +fn cache_posture( + registry: &CompiledRegistry, + resource: &CompiledResource, + access_profile: &CompiledAccessProfile, +) -> CachePosture { + let snapshot_source = registry + .sources + .iter() + .any(|source| source.id == resource.source && source.profile == SourceProfile::Snapshot); + if matches!(access_profile.access, CompiledAccess::Public) + && access_profile.processing_handling == Handling::Public + && snapshot_source + { + CachePosture::PublicRevalidate + } else { + CachePosture::NoStore + } +} + pub fn render_operation_explanation( report: &OperationExplanation, ) -> Result, IdentificationError> { @@ -944,7 +959,7 @@ fn query_explanation(operation: &CompiledOperation) -> QueryExplanation { .map(|bbox| SpatialQueryExplanation { parameter: "bbox".into(), crs: CRS84_URI.into(), - predicate: "inclusive-point-within-bbox".into(), + predicate: POINT_BBOX_PREDICATE.into(), maximum_longitude_span_degrees: bbox.maximum_longitude_span_degrees, maximum_latitude_span_degrees: bbox.maximum_latitude_span_degrees, }); @@ -2507,6 +2522,36 @@ mod tests { ); } + #[test] + fn public_snapshot_source_is_explained_as_public_revalidation() { + let mut registry = public_registry(SourceProfile::Snapshot); + let source_metadata_canary = "SOURCE_METADATA_CANARY_4ee4ba"; + registry.sources[0].expected_schema_fingerprint = source_metadata_canary.into(); + let digest = classification_inventory_digest(®istry).expect("inventory digest"); + + let explanation = operation_explanation(®istry, &digest).expect("explanation"); + let access_profile = &explanation.operations[0].access_profiles[0]; + assert_eq!(access_profile.cache.kind, CachePosture::PublicRevalidate); + let rendered = render_operation_explanation(&explanation).expect("canonical explanation"); + assert!(!String::from_utf8(rendered) + .expect("UTF-8 JSON") + .contains(source_metadata_canary)); + } + + #[test] + fn public_live_source_is_explained_as_no_store() { + let registry = public_registry(SourceProfile::LiveReadOnly); + let digest = classification_inventory_digest(®istry).expect("inventory digest"); + + let explanation = operation_explanation(®istry, &digest).expect("explanation"); + let access_profile = &explanation.operations[0].access_profiles[0]; + assert_eq!(access_profile.cache.kind, CachePosture::NoStore); + assert!(matches!( + access_profile.access, + AccessPolicyExplanation::Public + )); + } + #[test] fn operation_explanation_is_canonical_value_free_and_complete_for_spatial_search() { let contract = compiler_tests::spatial_contract(true); @@ -2639,4 +2684,17 @@ mod tests { assert!(text.contains("format-profiles=rfc7946, jsonfg")); assert!(!text.contains(canary)); } + + fn public_registry(profile: SourceProfile) -> CompiledRegistry { + let contract = compiler_tests::spatial_contract(false); + let mut registry = compile_contract_with_governed_files( + &contract, + &[compiler_tests::spatial_observed_schema()], + CompileProfile::Production, + &compiler_tests::governed_files_for(&contract), + ) + .expect("public contract compiles"); + registry.sources[0].profile = profile; + registry + } } diff --git a/crates/registry-relay-v2/src/model.rs b/crates/registry-relay-v2/src/model.rs index 8005c5eee..6bc166566 100644 --- a/crates/registry-relay-v2/src/model.rs +++ b/crates/registry-relay-v2/src/model.rs @@ -370,6 +370,9 @@ pub struct CompiledSpatialBboxQuery { pub maximum_latitude_span_degrees: u16, } +/// Stable capability identifier shared by discovery, generated artifacts, and explanations. +pub const POINT_BBOX_PREDICATE: &str = "inclusive-point-within-bbox"; + #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct CompiledFilter { diff --git a/crates/registry-relay-v2/src/tooling.rs b/crates/registry-relay-v2/src/tooling.rs index bba8371ac..162eaef40 100644 --- a/crates/registry-relay-v2/src/tooling.rs +++ b/crates/registry-relay-v2/src/tooling.rs @@ -393,6 +393,10 @@ fn collect_configuration_key_paths(document: &serde_json::Value) -> Vec | "resources[].sourceColumnClassifications" | "resources[].properties" | "resources[].disclosureProfiles" + | "resources[].operations.list.accessProfiles" + | "resources[].operations.read.accessProfiles" + | "resources[].operations.lookups[].accessProfiles" + | "resources[].operations.searches[].accessProfiles" | "resources[].operations.lookups[].requestBody.selectors" ); if dynamic_map { @@ -1279,6 +1283,31 @@ mod tests { assert!(value["details"].get("operation_explanation").is_none()); } + #[test] + fn access_profile_identifiers_are_wildcarded_in_configuration_key_paths() { + let document = serde_json::json!({ + "resources": [{ + "operations": { + "list": {"accessProfiles": {"acceptance-list": {"access": "public"}}}, + "read": {"accessProfiles": {"acceptance-read": {"access": "public"}}}, + "lookups": [{"accessProfiles": {"acceptance-lookup": {"access": "public"}}}], + "searches": [{"accessProfiles": {"acceptance-search": {"access": "public"}}}] + } + }] + }); + let paths = collect_configuration_key_paths(&document); + for prefix in [ + "resources[].operations.list.accessProfiles", + "resources[].operations.read.accessProfiles", + "resources[].operations.lookups[].accessProfiles", + "resources[].operations.searches[].accessProfiles", + ] { + assert!(paths.contains(&format!("{prefix}.*")), "{paths:?}"); + assert!(paths.contains(&format!("{prefix}.*.access")), "{paths:?}"); + } + assert!(paths.iter().all(|path| !path.contains("acceptance-"))); + } + #[test] fn refused_check_returns_no_partial_explanation() { let temporary = tempfile::tempdir().expect("temporary project creates"); @@ -1376,4 +1405,39 @@ mod tests { assert!(!rendered.contains(protected), "report leaked fixture data"); } } + + #[test] + fn selected_fixture_executes_and_asserts_its_minimal_prerequisite_closure() { + let project_name = ["business", "registry"].join("-"); + let project = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../products/relay-v2/acceptance") + .join(project_name); + let selected = "premises-feature-collection-jsonfg"; + let report = test_project(&TestOptions { + project_root: project, + fixture_id: Some(selected.into()), + }) + .expect("selected fixture operation completes"); + assert!(report.is_success(), "{report:?}"); + let ToolingDetails::Test { + report: Some(fixture_report), + .. + } = report.details + else { + panic!("fixture execution returns its plan report"); + }; + assert_eq!(fixture_report.selected_fixture.as_deref(), Some(selected)); + assert_eq!( + fixture_report + .steps + .iter() + .map(|step| step.id.as_str()) + .collect::>(), + ["premises-first-page", selected] + ); + assert!(fixture_report + .steps + .iter() + .all(|step| step.passed == Some(true))); + } } diff --git a/products/relay-v2/CONFIGURATION-EXAMPLES.md b/products/relay-v2/CONFIGURATION-EXAMPLES.md index 8f62fe58e..41a529840 100644 --- a/products/relay-v2/CONFIGURATION-EXAMPLES.md +++ b/products/relay-v2/CONFIGURATION-EXAMPLES.md @@ -862,21 +862,12 @@ resources[].id resources[].operations resources[].operations.list resources[].operations.list.accessProfiles -resources[].operations.list.accessProfiles.public-register -resources[].operations.list.accessProfiles.public-register.access -resources[].operations.list.accessProfiles.public-register.disclosureProfile -resources[].operations.list.accessProfiles.registrar -resources[].operations.list.accessProfiles.registrar-premises -resources[].operations.list.accessProfiles.registrar-premises.access -resources[].operations.list.accessProfiles.registrar-premises.access.authorityRowBinding -resources[].operations.list.accessProfiles.registrar-premises.access.purpose -resources[].operations.list.accessProfiles.registrar-premises.access.scope -resources[].operations.list.accessProfiles.registrar-premises.disclosureProfile -resources[].operations.list.accessProfiles.registrar.access -resources[].operations.list.accessProfiles.registrar.access.authorityRowBinding -resources[].operations.list.accessProfiles.registrar.access.purpose -resources[].operations.list.accessProfiles.registrar.access.scope -resources[].operations.list.accessProfiles.registrar.disclosureProfile +resources[].operations.list.accessProfiles.* +resources[].operations.list.accessProfiles.*.access +resources[].operations.list.accessProfiles.*.access.authorityRowBinding +resources[].operations.list.accessProfiles.*.access.purpose +resources[].operations.list.accessProfiles.*.access.scope +resources[].operations.list.accessProfiles.*.disclosureProfile resources[].operations.list.allowUnfiltered resources[].operations.list.defaultAccessProfile resources[].operations.list.filters @@ -892,50 +883,17 @@ resources[].operations.list.pagination.maximumPageSize resources[].operations.lookups resources[].operations.lookups[] resources[].operations.lookups[].accessProfiles -resources[].operations.lookups[].accessProfiles.caseworker -resources[].operations.lookups[].accessProfiles.caseworker.access -resources[].operations.lookups[].accessProfiles.caseworker.access.authorityRowBinding -resources[].operations.lookups[].accessProfiles.caseworker.access.authorityRowBinding.claim -resources[].operations.lookups[].accessProfiles.caseworker.access.authorityRowBinding.sourceColumn -resources[].operations.lookups[].accessProfiles.caseworker.access.purpose -resources[].operations.lookups[].accessProfiles.caseworker.access.purpose.allowed -resources[].operations.lookups[].accessProfiles.caseworker.access.purpose.allowed[] -resources[].operations.lookups[].accessProfiles.caseworker.access.purpose.claim -resources[].operations.lookups[].accessProfiles.caseworker.access.scope -resources[].operations.lookups[].accessProfiles.caseworker.disclosureProfile -resources[].operations.lookups[].accessProfiles.limited -resources[].operations.lookups[].accessProfiles.limited.access -resources[].operations.lookups[].accessProfiles.limited.access.authorityRowBinding -resources[].operations.lookups[].accessProfiles.limited.access.authorityRowBinding.claim -resources[].operations.lookups[].accessProfiles.limited.access.authorityRowBinding.sourceColumn -resources[].operations.lookups[].accessProfiles.limited.access.purpose -resources[].operations.lookups[].accessProfiles.limited.access.purpose.allowed -resources[].operations.lookups[].accessProfiles.limited.access.purpose.allowed[] -resources[].operations.lookups[].accessProfiles.limited.access.purpose.claim -resources[].operations.lookups[].accessProfiles.limited.access.scope -resources[].operations.lookups[].accessProfiles.limited.disclosureProfile -resources[].operations.lookups[].accessProfiles.registrar-verification -resources[].operations.lookups[].accessProfiles.registrar-verification.access -resources[].operations.lookups[].accessProfiles.registrar-verification.access.authorityRowBinding -resources[].operations.lookups[].accessProfiles.registrar-verification.access.authorityRowBinding.claim -resources[].operations.lookups[].accessProfiles.registrar-verification.access.authorityRowBinding.sourceColumn -resources[].operations.lookups[].accessProfiles.registrar-verification.access.purpose -resources[].operations.lookups[].accessProfiles.registrar-verification.access.purpose.allowed -resources[].operations.lookups[].accessProfiles.registrar-verification.access.purpose.allowed[] -resources[].operations.lookups[].accessProfiles.registrar-verification.access.purpose.claim -resources[].operations.lookups[].accessProfiles.registrar-verification.access.scope -resources[].operations.lookups[].accessProfiles.registrar-verification.disclosureProfile -resources[].operations.lookups[].accessProfiles.supervisory -resources[].operations.lookups[].accessProfiles.supervisory.access -resources[].operations.lookups[].accessProfiles.supervisory.access.authorityRowBinding -resources[].operations.lookups[].accessProfiles.supervisory.access.authorityRowBinding.claim -resources[].operations.lookups[].accessProfiles.supervisory.access.authorityRowBinding.sourceColumn -resources[].operations.lookups[].accessProfiles.supervisory.access.purpose -resources[].operations.lookups[].accessProfiles.supervisory.access.purpose.allowed -resources[].operations.lookups[].accessProfiles.supervisory.access.purpose.allowed[] -resources[].operations.lookups[].accessProfiles.supervisory.access.purpose.claim -resources[].operations.lookups[].accessProfiles.supervisory.access.scope -resources[].operations.lookups[].accessProfiles.supervisory.disclosureProfile +resources[].operations.lookups[].accessProfiles.* +resources[].operations.lookups[].accessProfiles.*.access +resources[].operations.lookups[].accessProfiles.*.access.authorityRowBinding +resources[].operations.lookups[].accessProfiles.*.access.authorityRowBinding.claim +resources[].operations.lookups[].accessProfiles.*.access.authorityRowBinding.sourceColumn +resources[].operations.lookups[].accessProfiles.*.access.purpose +resources[].operations.lookups[].accessProfiles.*.access.purpose.allowed +resources[].operations.lookups[].accessProfiles.*.access.purpose.allowed[] +resources[].operations.lookups[].accessProfiles.*.access.purpose.claim +resources[].operations.lookups[].accessProfiles.*.access.scope +resources[].operations.lookups[].accessProfiles.*.disclosureProfile resources[].operations.lookups[].defaultAccessProfile resources[].operations.lookups[].id resources[].operations.lookups[].requestBody @@ -949,42 +907,27 @@ resources[].operations.lookups[].requestBody.selectors.*.sourceColumn resources[].operations.lookups[].requestBody.selectors.*.type resources[].operations.read resources[].operations.read.accessProfiles -resources[].operations.read.accessProfiles.public-premises -resources[].operations.read.accessProfiles.public-premises.access -resources[].operations.read.accessProfiles.public-premises.disclosureProfile -resources[].operations.read.accessProfiles.public-register -resources[].operations.read.accessProfiles.public-register.access -resources[].operations.read.accessProfiles.public-register.disclosureProfile -resources[].operations.read.accessProfiles.registrar -resources[].operations.read.accessProfiles.registrar-premises -resources[].operations.read.accessProfiles.registrar-premises.access -resources[].operations.read.accessProfiles.registrar-premises.access.authorityRowBinding -resources[].operations.read.accessProfiles.registrar-premises.access.purpose -resources[].operations.read.accessProfiles.registrar-premises.access.scope -resources[].operations.read.accessProfiles.registrar-premises.disclosureProfile -resources[].operations.read.accessProfiles.registrar.access -resources[].operations.read.accessProfiles.registrar.access.authorityRowBinding -resources[].operations.read.accessProfiles.registrar.access.authorityRowBinding.claim -resources[].operations.read.accessProfiles.registrar.access.authorityRowBinding.sourceColumn -resources[].operations.read.accessProfiles.registrar.access.purpose -resources[].operations.read.accessProfiles.registrar.access.purpose.allowed -resources[].operations.read.accessProfiles.registrar.access.purpose.allowed[] -resources[].operations.read.accessProfiles.registrar.access.purpose.claim -resources[].operations.read.accessProfiles.registrar.access.scope -resources[].operations.read.accessProfiles.registrar.disclosureProfile +resources[].operations.read.accessProfiles.* +resources[].operations.read.accessProfiles.*.access +resources[].operations.read.accessProfiles.*.access.authorityRowBinding +resources[].operations.read.accessProfiles.*.access.authorityRowBinding.claim +resources[].operations.read.accessProfiles.*.access.authorityRowBinding.sourceColumn +resources[].operations.read.accessProfiles.*.access.purpose +resources[].operations.read.accessProfiles.*.access.purpose.allowed +resources[].operations.read.accessProfiles.*.access.purpose.allowed[] +resources[].operations.read.accessProfiles.*.access.purpose.claim +resources[].operations.read.accessProfiles.*.access.scope +resources[].operations.read.accessProfiles.*.disclosureProfile resources[].operations.read.defaultAccessProfile resources[].operations.searches resources[].operations.searches[] resources[].operations.searches[].accessProfiles -resources[].operations.searches[].accessProfiles.public-premises -resources[].operations.searches[].accessProfiles.public-premises.access -resources[].operations.searches[].accessProfiles.public-premises.disclosureProfile -resources[].operations.searches[].accessProfiles.registrar-premises -resources[].operations.searches[].accessProfiles.registrar-premises.access -resources[].operations.searches[].accessProfiles.registrar-premises.access.authorityRowBinding -resources[].operations.searches[].accessProfiles.registrar-premises.access.purpose -resources[].operations.searches[].accessProfiles.registrar-premises.access.scope -resources[].operations.searches[].accessProfiles.registrar-premises.disclosureProfile +resources[].operations.searches[].accessProfiles.* +resources[].operations.searches[].accessProfiles.*.access +resources[].operations.searches[].accessProfiles.*.access.authorityRowBinding +resources[].operations.searches[].accessProfiles.*.access.purpose +resources[].operations.searches[].accessProfiles.*.access.scope +resources[].operations.searches[].accessProfiles.*.disclosureProfile resources[].operations.searches[].defaultAccessProfile resources[].operations.searches[].id resources[].operations.searches[].orderBy diff --git a/products/relay-v2/contracts/generated-baselines.yaml b/products/relay-v2/contracts/generated-baselines.yaml index 447de6ae7..10c3dfed6 100644 --- a/products/relay-v2/contracts/generated-baselines.yaml +++ b/products/relay-v2/contracts/generated-baselines.yaml @@ -252,7 +252,7 @@ projects: size: 6470 visibility: operator-only business-registry: - packageRevision: sha256:8b2b7ef4d57b9fa78450ddddcfd2054769fc8c01f5d36435a4312c1f268f740c + packageRevision: sha256:29d37d6bf8ca1d278b5cbbda0cf9bd47c1c79da0195ba5a0c767cf127b913163 contractRevision: sha256:f72669730175ad097512fa9eda378bbbd3bbb64a859615e42d4752b277630968 sourceSchemaFingerprints: companies: sha256:dd62b98578f0fa7341eeeaaac4b34da9b79405ae067dc06e5edb004c2d4a38fe @@ -269,14 +269,14 @@ projects: mediaType: application/json operationIdentifier: null path: generated/artifacts/capabilities.full.json - sha256: sha256:af5457dd3c685d914da14b911a4d8d34138bbba695e4b90f871b934ca4bc2f0e + sha256: sha256:84581ff4973a06efc9c383930a873cdc422595381aa278f9c01cb7eea4904abf visibility: operator-only - accessProfileIdentifier: null id: capability-inventory mediaType: application/json operationIdentifier: null path: generated/artifacts/capabilities.json - sha256: sha256:eb7db7231f6e902cef236eea3ed32cf074d202ce3a44f56fa2a9c1169f624a08 + sha256: sha256:3b1bb8c5dfc320a6565ae01e2d53eee1dc44b41719c5dc1d1872b3b0f46e1a3c visibility: public - accessProfileIdentifier: null id: registered-business--list--access-profile-public-register-classifications @@ -738,7 +738,7 @@ projects: mediaType: application/json operationIdentifier: registered-premises.search.within-bbox path: generated/artifacts/registered-premises--search-within-bbox--access-profile-registrar-premises.capability.json - sha256: sha256:35eede13bf9ee7c706a853d1f266cd45d9becb888ab295b8ea46e8e2381ee755 + sha256: sha256:75549a4599894196e026203ceb9aca253299bf4dff50db17b7f3078cd464ff6f visibility: operation-bound - accessProfileIdentifier: registrar-premises id: registered-premises--search-within-bbox--access-profile-registrar-premises-classifications @@ -836,14 +836,14 @@ projects: mediaType: application/yaml operationIdentifier: null path: generated/openapi.full.yaml - sha256: sha256:0f632e9d0751702b6ab682faca7870909a1e375852db214320de8841c5b62979 + sha256: sha256:e142704c094faeb697e26eb9195cc1593f9623147daaa94d356cdc83c810b824 visibility: operator-only - accessProfileIdentifier: null id: openapi-public mediaType: application/json operationIdentifier: null path: generated/openapi.public.json - sha256: sha256:d8cda77c932d8514ab32236d425957bef50d03cc0c16b6bd712939d3e0abd4de + sha256: sha256:e0dd38413b3ba98a80a9b42014f564733b38f8af51cf12674591af88870280e8 visibility: public governedFiles: - generated: false From 37430e56c88be7ee0a23dddc9a2ef34eba3058fe Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 10 Aug 2026 17:51:35 +0700 Subject: [PATCH 21/24] docs(relay): align access profile guidance Signed-off-by: Jeremi Joslin --- .../governed-registry-publication.mdx | 51 ++++++++++--------- .../relay-semantics-and-disclosure.mdx | 47 +++++++++-------- docs/site/src/content/docs/operate/relay.mdx | 36 ++++++------- .../src/content/docs/reference/standards.mdx | 12 ++--- .../publish-governed-sqlite-registry.mdx | 2 +- docs/site/src/data/generated/standards.json | 20 ++++---- docs/site/src/data/standards.yaml | 20 ++++---- 7 files changed, 96 insertions(+), 92 deletions(-) diff --git a/docs/site/src/content/docs/explanation/governed-registry-publication.mdx b/docs/site/src/content/docs/explanation/governed-registry-publication.mdx index 1af43dc5a..86f226e82 100644 --- a/docs/site/src/content/docs/explanation/governed-registry-publication.mdx +++ b/docs/site/src/content/docs/explanation/governed-registry-publication.mdx @@ -1,6 +1,6 @@ --- title: How Relay publishes a governed Registry -description: Understand how one reviewed Registry contract governs SQLite reads, representations, audit, and startup behavior. +description: Understand how one reviewed Registry contract governs SQLite reads, access profiles, wire formats, audit, and startup behavior. status: draft owner: registry-docs source_repos: @@ -19,7 +19,7 @@ standards_referenced: Registry Relay exposes selected, read-only Registry Records without making the source database a general API. One reviewed contract connects Registry identity, SQLite views, mandatory Registry Core context, -operations, representations, processing, disclosure, generated artifacts, and audit. +operations, access profiles, wire formats, processing, disclosure, generated artifacts, and audit. ## Start with one Registry @@ -30,7 +30,7 @@ Unbound database objects remain invisible. Each successful Record preserves Registry Core: Registry and Record identifiers, revision, lifecycle state, Authority, recorded time, response-schema reference, and semantic-model reference. -Only `domainData` varies by representation and requester field subset. +Only `domainData` varies by access profile and requester field subset. The pair `(registryIdentifier, recordIdentifier)` remains authoritative when JSON for Linked Data (JSON-LD) adds a derived `@id` and the resource semantic class as `@type`. @@ -43,25 +43,25 @@ plans, and generates OpenAPI and semantic artifacts. Runtime configuration supplies deployment-local paths, listener, issuer, audit sink, limits, and secrets. -It cannot add or weaken a resource, operation, representation, access rule, classification, +It cannot add or weaken a resource, operation, access profile, access rule, classification, semantic mapping, or disclosure decision. The service loads one complete checked package at startup. It has no hot reload, overlay, partial activation, fallback interpretation, or mutable authoring project input. -## Publish representations, not database columns +## Publish access profiles, not database columns -Each compiled list, identifier-read, or named exact-lookup operation has a finite map of named -representations and one default. -The representation binds one access rule to one disclosure profile. +Each compiled list, identifier-read, named exact-lookup, or named search operation has a finite map +of named access profiles and one default. +The access profile binds one access rule to one disclosure profile. The profile is the maximum published-property set for that response. -Callers choose the default by omitting `representation`, or request a supplied representation by +Callers choose the default by omitting `accessProfile`, or request a supplied access profile by name. Relay authorizes the exact choice and never falls back when it is unknown or denied. A syntactically valid unknown name and a scope-hidden name share the generic -`404 resource.not_found` response, so callers cannot enumerate the finite representation map. -`fields` runs after representation selection and can only narrow `domainData` within that profile. +`404 resource.not_found` response, so callers cannot enumerate the finite access-profile map. +`fields` runs after access-profile selection and can only narrow `domainData` within that profile. It cannot introduce SQL, source columns, joins, filters, ordering, expressions, transformations, or new authorization. Registry Core cannot be removed. @@ -69,24 +69,25 @@ Registry Core cannot be removed. The compiler keeps source processing and disclosure distinct. Processing handling covers all columns Relay uses, including hidden identifiers, filters, and row boundaries. -Disclosure handling covers the serializable properties of the representation. +Disclosure handling covers the serializable properties of the access profile. The processing floor drives compiler validity, source projection, cache eligibility, and audit -context. Authorization remains the representation's explicit access rule. +context. Authorization remains the access profile's explicit access rule. ## Keep spatial output inside the same boundary A resource can declare one classified primary Point assembled from reviewed CRS84 longitude and latitude columns. The Point enters an ordinary disclosure profile by its published property name. -A representation that omits that property cannot negotiate GeoJSON, while a representation that -includes it can serialize the same governed Record as JSON, JSON-LD, RFC 7946 GeoJSON, or the -bounded JSON-FG profile. `Accept` changes the wire format, not the authorization decision. - -A list can add one publisher-bounded `bbox` query over that Point. Relay classifies the capability -as constrained `consultation.search`, binds the predicate to the reviewed columns, and carries the -bbox, selected representation, wire format, and profile inside the encrypted cursor context. +An access profile that omits that property cannot negotiate GeoJSON, while a profile that includes +it can serialize the same governed Record as JSON, JSON-LD, RFC 7946 GeoJSON, or the bounded +JSON-FG profile. `Accept` and `formatProfile` change serialization, not authorization. + +A separately named search can declare one publisher-bounded `point-bbox` query over that Point. +Relay classifies it as constrained `consultation.search`, binds the predicate to the reviewed +columns, and carries the bbox, selected access profile, wire format, and format profile inside the +encrypted cursor context. List and search can require different scopes. The initial profile has no alternate coordinate reference system, antimeridian traversal, generic -geometry, spatial join, or OGC API Features route. The [spatial acceptance tests](https://github.com/registrystack/registry-stack/blob/d7b96d7d4aa89754c0f2ca6fe3d4d697e9e9e8ad/crates/registry-relay-v2/tests/acceptance_http.rs) -exercise the shared authorization, disclosure, audit, and cache boundary. +geometry, spatial join, or OGC API Features route. The synthetic tutorial exercises the same +authorization, disclosure, audit, and cache boundary through the Relay router. ## Offer only declared read capabilities @@ -97,7 +98,7 @@ Relay compiles only the operations the publisher declares: | Identifier read | `consultation.retrieve` | One Record by stable identifier. | | Deterministic list | `consultation.list` | A bounded collection with declared equality filters and order. | | Named exact lookup | `consultation.search` | One resolved Record or an indistinguishable unresolved outcome. | -| Bounded Point search | `consultation.search` | A deterministic list constrained by one declared CRS84 bbox. | +| Bounded Point search | `consultation.search` | A deterministic collection search constrained by one declared CRS84 bbox. | Exact lookup is not record matching: Relay returns no candidates, scores, rankings, or matching explanations. @@ -130,7 +131,7 @@ relation and do not claim certification or conformance. Relay records a durable attempt before source access and a terminal event before the exact response bytes leave the service. Failure of either audit gate refuses source access or withholds the response. -The events are value-free: they bind the Registry, resource, operation, representation, disclosure +The events are value-free: they bind the Registry, resource, operation, access profile, disclosure profile, selected properties, processing and disclosure handling, transform identifiers, contract revision, and source revision, but exclude tokens, selectors, raw principals, source values, and response values. @@ -143,7 +144,7 @@ Registry Mint is an optional token issuer rather than a Relay runtime dependency ## Next - [Review semantics, classification, and disclosure](../relay-semantics-and-disclosure/) for the - representation and governance model. + access-profile and governance model. - [Publish a governed SQLite registry](../../tutorials/publish-governed-sqlite-registry/) for a synthetic end-to-end run. - [Operate Registry Relay](../../operate/relay/) for deployment controls. diff --git a/docs/site/src/content/docs/explanation/relay-semantics-and-disclosure.mdx b/docs/site/src/content/docs/explanation/relay-semantics-and-disclosure.mdx index fa79b58b9..78a1b6258 100644 --- a/docs/site/src/content/docs/explanation/relay-semantics-and-disclosure.mdx +++ b/docs/site/src/content/docs/explanation/relay-semantics-and-disclosure.mdx @@ -1,6 +1,6 @@ --- title: Semantics, classification, and disclosure in Relay -description: Understand how Relay separates source processing, public meaning, reviewed representations, and bounded disclosure. +description: Understand how Relay separates source processing, public meaning, access profiles, and bounded disclosure. status: draft owner: registry-docs source_repos: @@ -36,7 +36,7 @@ It does not profile or read row values, make machine-learning inferences, or aut Candidates record their evidence and categorical confidence. Conflicts remain uncertain and require institutional review. -## Preserve Registry Core in every representation +## Preserve Registry Core in every wire format Every successful Record has mandatory Registry Core context: Registry and Record identifiers, revision, lifecycle state, Authority, recorded time, and links to the response schema and semantic @@ -48,8 +48,8 @@ This split explains why Relay produces two kinds of validation artifact. The compiler validates hidden source bindings. The complete-record schema and Shape Constraint Language (SHACL) shape retain source requiredness for every governed output property. -Each representation schema validates Registry Core and the domain properties that representation -permits when they are present. +Each operation response schema validates Registry Core and the domain properties that its access +profile permits when they are present. ## Classify output and processing separately @@ -60,15 +60,15 @@ The source classification is not a duplicate of the published property's classif the data Relay had to process even when Relay does not serialize it. The technical handling order is `public`, `internal`, `confidential`, then `restricted`. -The compiler derives two floors for each representation: +The compiler derives two floors for each access profile: | Floor | Includes | Controls | | --- | --- | --- | | Processing handling | Every source column used to answer the operation | Compiler validity, source use, audit context, and cache eligibility. | -| Disclosure handling | Properties serializable by the representation | The sensitivity of the releasable output. | +| Disclosure handling | Properties serializable by the access profile | The sensitivity of the releasable output. | The processing floor applies even when the output floor is less restrictive. -For example, a public representation cannot conceal a non-public source column through a transform. +For example, a public access profile cannot conceal a non-public source column through a transform. It must instead read a reviewed pre-derived public column from the SQLite view. Classifications may reduce availability, but do not grant access. @@ -79,7 +79,7 @@ They do not create a scope, purpose, row authority, lawful basis, consent decisi Resource defaults can make authoring concise, but compilation expands every property and processed source column to a complete effective classification. `relayctl generate` produces deterministic, value-free identification, classification inventory, -representation, and contextual-finding reports, plus a classification-review starter. +operation-explanation, and contextual-finding reports, plus a classification-review starter. The closed `ClassificationReview` sidecar binds the Registry identifier and classification-inventory digest to a reviewer, date, rationale, status, and one method: `generated`, `imported`, or `manual`. @@ -87,22 +87,25 @@ Generated review also binds the accepted copied identification report and rule p Imported and manual reviews are first-class methods and do not require an identification report. Production compilation fails for a missing, unreviewed, stale, or digest-mismatched sidecar. -## Use finite governed representations +## Use finite governed access profiles -An operation has a finite ordered map of named representations and exactly one default. -Each representation owns one access rule and one disclosure profile. +An operation has a finite ordered map of named access profiles and exactly one default. +Each access profile owns one access rule and one disclosure profile. The profile is the largest set of published properties it may disclose. -The request parameter `representation` selects a named representation. +The request parameter `accessProfile` selects a named access profile. When absent, Relay uses the declared default. -When present, Relay authorizes that exact representation: an invalid bearer, denied request, or -unknown name does not fall back to another representation. +When present, Relay authorizes that exact access profile: an invalid bearer, denied request, or +unknown name does not fall back to another access profile. A syntactically valid unknown name and a scope-hidden name share the generic -`404 resource.not_found` response, so the finite representation map is not enumerable. -After selection, `fields` may request only a non-empty subset of that representation's properties. +`404 resource.not_found` response, so the finite access-profile map is not enumerable. +After selection, `fields` may request only a non-empty subset of that profile's properties. It cannot select a source column, switch profiles, change a transform, bypass a row boundary, or lower the compiled handling, audit, quota, metadata, or cache controls. +The `Accept` header and optional `formatProfile` select serialization only after access and +disclosure are fixed. They cannot grant a scope, widen fields, or select a different query. + This is requester minimization, not dynamic per-request masking or attribute authorization. Relay has no free-form policy engine or arbitrary expression language. @@ -126,7 +129,7 @@ Optional null input omits the transformed property. Relay does not provide hashing, pseudonyms, encryption, regular-expression replacement, geographic or numeric transformations, codelist remapping, caller-defined masks, or dynamic masking policy. -A public representation cannot use a transform to conceal a non-public source column. It must read +A public access profile cannot use a transform to conceal a non-public source column. It must read a reviewed pre-derived public value from the SQLite view. Transformed properties also cannot be list filters or fixed-order keys. Queryable derived values must be separate reviewed pre-derived source properties, so filters and ordering never operate on @@ -140,22 +143,22 @@ reviewed source-column bindings. Relay validates both coordinates as one complet releasing any response, and a malformed selected row fails closed as value-free `503 source.unavailable`. -The selected representation controls whether the Point is disclosed. Ordinary JSON and JSON-LD +The selected access profile controls whether the Point is disclosed. Ordinary JSON and JSON-LD place the Point in `domainData`. GeoJSON moves the same selected Point to the Feature `geometry` member and leaves Registry Core plus the other selected properties in Feature `properties`. If the caller narrows `fields` to omit the Point, GeoJSON uses an explicit `null` geometry rather than reintroducing the coordinates elsewhere. Generated vocabulary, JSON Schema, SHACL, classification, and capability artifacts describe the -Point only for audiences that can receive the selected representation. The JSON-LD context types +Point only for audiences that can receive the selected access profile. The JSON-LD context types the bounded GeoJSON value as RDF JSON. Relay does not claim GeoSPARQL inference, geometry -conversion, or an OGC API Features service. The [generated artifact tests](https://github.com/registrystack/registry-stack/blob/d7b96d7d4aa89754c0f2ca6fe3d4d697e9e9e8ad/crates/registry-relay-v2/src/artifacts.rs) -pin those limits. +conversion, or an OGC API Features service. Generated schemas and the synthetic HTTP journey pin +those limits. ## Generate semantics without pretending equivalence Relay generates a local JSON for Linked Data (JSON-LD) vocabulary and context, JSON Schema, SHACL, -codelist schemas, capability metadata, and representation-specific artifacts. +codelist schemas, capability metadata, and operation-specific response artifacts. The context expands response `data` and `items` as RDF graph containers, aliases `domainData` to JSON-LD `@nest`, emits the resource semantic class as `@type`, and applies the same IRI and XML Schema datatypes required by the operation-bound SHACL shape. diff --git a/docs/site/src/content/docs/operate/relay.mdx b/docs/site/src/content/docs/operate/relay.mdx index aa9e8b145..7a4df4428 100644 --- a/docs/site/src/content/docs/operate/relay.mdx +++ b/docs/site/src/content/docs/operate/relay.mdx @@ -14,7 +14,7 @@ standards_referenced: --- Deploy one reviewed Relay package without allowing deployment configuration to change Registry -identity, Registry Core, source bindings, representations, access, classifications, or disclosure. +identity, Registry Core, source bindings, access profiles, wire formats, classifications, or disclosure. This guide is for the operator who owns service paths, sources, secrets, issuers, audit retention, limits, readiness, and replacement of complete revisions. @@ -33,7 +33,7 @@ administrative trust boundary. Prepare a dedicated Unix service identity, a private listener behind Transport Layer Security (TLS) termination, a sealed package, the matching snapshot or live read-only source, an audit location, independent audit-integrity and cursor-encryption keys, and a token issuer for protected -representations. +access profiles. Do not place the package, source, secret, or audit path in a shared writable directory. For snapshot mode, make the SQLite file immutable outside Relay, preferably with a read-only mount. Relay verifies its captured digest before and after every statement, but a process cannot exclude a @@ -64,17 +64,17 @@ limits: {requestTimeoutMilliseconds: 1500, concurrentQueries: 32} quotas: {requestsPerMinute: 120, burst: 20} ``` -`authentication.issuer: null` is valid only when every compiled representation is public. -A package with a protected representation needs the configured issuer at startup. +`authentication.issuer: null` is valid only when every compiled access profile is public. +A package with a protected access profile needs the configured issuer at startup. The issuer's verified claims may establish scopes, purpose, and row authority, but cannot enable an -operation or representation the package did not compile. -A syntactically valid unknown representation and a valid principal without the selected -representation scope receive the same concealed `404 resource.not_found` response. Relay does not -fall back to a less restrictive representation. +operation or access profile the package did not compile. +A syntactically valid unknown access profile and a valid principal without its scope receive the +same concealed `404 resource.not_found` response. Relay does not fall back to a less restrictive +access profile. Relay authenticates and encrypts the complete cursor payload with a fresh nonce. The payload binds -the source and contract revisions, operation, representation, disclosure profile, filters, fixed -order, selected fields, authorization context, optional bbox, response format, GeoJSON profile, +the source and contract revisions, operation, access profile, disclosure profile, filters, fixed +order, selected fields, authorization context, optional bbox, wire format, format profile, and expiry. Treat cursors as opaque continuation tokens even though they contain no plaintext filter, order, or bbox values. @@ -85,8 +85,8 @@ There is no deployment switch that disables fail-closed audit behavior. Quota configuration has two values for the whole Relay deployment. `requestsPerMinute` sets the refill rate and `burst` sets the short-term capacity. Relay keeps a separate bucket for each -compiled operation, so one busy operation cannot exhaust another. Every representation of an -operation shares that operation's bucket. Version 1 does not add per-representation, per-client, +compiled operation, so one busy operation cannot exhaust another. Every access profile of an +operation shares that operation's bucket. Version 1 does not add per-profile, per-client, or distributed quota modes. Put those controls at the trusted gateway when the deployment needs them. @@ -100,14 +100,14 @@ Runtime cannot change it. | Publisher updates while Relay runs | No | Yes, through a separate trusted publisher. | | Source revision | Captured content digest | Explicitly unversioned. | | List and cursor pagination | Supported | Not supported. | -| Cache revalidation | Available only for eligible public representations | Disabled. | +| Cache revalidation | Available only for eligible public access profiles | Disabled. | | Request consistency | Immutable file | One read transaction. | Both profiles pin the SQLite schema fingerprint and deny writes, arbitrary SQL, schema drift, and unbounded result behavior. Live resources support only identifier read and named exact lookup. -Bounded bbox consultation is a list operation, so it uses a reviewed snapshot source in this -profile. GeoJSON and JSON-FG do not change the source, access, audit, or cache rules. Their exact +Bounded bbox consultation is a named collection search, so it uses a reviewed snapshot source in +this profile. GeoJSON and JSON-FG do not change the source, access, audit, or cache rules. Their exact response bytes receive distinct cache validators where public snapshot revalidation is eligible. ## Start and verify the service @@ -138,7 +138,7 @@ SQLite access and a terminal release, unresolved, or source-failed event before The release event covers the exact response bytes. An audit failure blocks source access or withholds the response. -Audit binds the Registry, resource, operation, representation, disclosure profile, selected +Audit binds the Registry, resource, operation, access profile, disclosure profile, selected property identifiers or digest, processing handling, disclosure handling, transform identifiers, contract revision, and source revision. It excludes tokens, selectors, raw principals, source values, response values, and raw Record @@ -168,6 +168,6 @@ Rollback activates a complete prior package only with its compatible source and | --- | --- | --- | | Startup refuses a path | A component is a symlink, has the wrong owner, or is writable | Move the deployment to trusted Unix paths and correct ownership and modes. | | Startup reports a package or schema error | The package, source, or review revision does not match | Stop deployment and return the change to the authoring workflow. | -| A protected representation returns `404 resource.not_found` | The issuer or token does not satisfy that representation's exact scope | Correct the issuer or caller authority. Do not expose a weaker representation as fallback. | -| A spatial request returns `406 representation.unsupported` | The selected representation does not disclose a primary geometry or the `Accept` value is unsupported | Select an entitled geometry-bearing representation or request JSON or JSON-LD. | +| A protected access profile returns `404 resource.not_found` | The issuer or token does not satisfy that profile's exact scope | Correct the issuer or caller authority. Do not expose a weaker profile as fallback. | +| A spatial request returns `406 format.unsupported` | The selected access profile does not disclose a primary geometry or the format request is unsupported | Select an entitled geometry-bearing profile or request JSON or JSON-LD. | | A response is withheld | The terminal audit write failed | Restore the audit sink and verify its integrity before accepting traffic. | diff --git a/docs/site/src/content/docs/reference/standards.mdx b/docs/site/src/content/docs/reference/standards.mdx index 8c61a8234..9c75a37bb 100644 --- a/docs/site/src/content/docs/reference/standards.mdx +++ b/docs/site/src/content/docs/reference/standards.mdx @@ -61,13 +61,13 @@ Aggregate output supports JSON, CSV, and SDMX-JSON, with only JSON stable for 1. -## Relay V2 spatial representation profile +## Relay V2 spatial wire-format profile -Relay V2 uses GeoJSON and JSON-FG as optional representations of the same -governed Registry Record. The initial profile is one classified Point in -CRS84, with an exact bounded bounding-box query on an existing consultation -route. This does not claim OGC API Features support and does not inherit the -separate Relay 1.0 adapter described in the roster. +Relay V2 uses GeoJSON and JSON-FG as optional wire formats for the same governed Registry Record. +The initial profile is one classified Point in CRS84, with an exact bounded bounding-box query on +a separately named `consultation.search` operation. The operation's access profile remains the +authorization and maximum-disclosure boundary. This does not claim OGC API Features support and +does not inherit the separate Relay 1.0 adapter described in the roster. ## Register diff --git a/docs/site/src/content/docs/tutorials/publish-governed-sqlite-registry.mdx b/docs/site/src/content/docs/tutorials/publish-governed-sqlite-registry.mdx index f21e9d9a8..10ec9f650 100644 --- a/docs/site/src/content/docs/tutorials/publish-governed-sqlite-registry.mdx +++ b/docs/site/src/content/docs/tutorials/publish-governed-sqlite-registry.mdx @@ -128,7 +128,7 @@ Contract revision: sha256: Resource: registered-premises - Operation: search:within-bbox GET /v2/resources/registered-premises/searches/within-bbox consultation/search + Operation: registered-premises.search.within-bbox GET /v2/resources/registered-premises/searches/within-bbox consultation/search query capabilities: point-bbox: available (point-bbox-search-operation); required access profile: public-premises (default) diff --git a/docs/site/src/data/generated/standards.json b/docs/site/src/data/generated/standards.json index 69bba663d..677693e0f 100644 --- a/docs/site/src/data/generated/standards.json +++ b/docs/site/src/data/generated/standards.json @@ -200,16 +200,16 @@ ], "evidence_docs": [ { - "label": "Relay V2 spatial response acceptance tests", - "url": "https://github.com/registrystack/registry-stack/blob/545efaf9597647744ada29fdb43759a4d1fac996/crates/registry-relay-v2/tests/acceptance_http.rs" + "label": "Relay V2 governed SQLite tutorial", + "url": "/tutorials/publish-governed-sqlite-registry/" }, { - "label": "Relay V2 generated GeoJSON schema tests", - "url": "https://github.com/registrystack/registry-stack/blob/545efaf9597647744ada29fdb43759a4d1fac996/crates/registry-relay-v2/src/artifacts.rs" + "label": "Relay V2 authoring guide", + "url": "/configure/relay/" } ], "last_checked": "2026-08-10", - "notes": "Relay V2 emits RFC 7946-shaped Point Features and FeatureCollections only when the selected governed representation discloses the primary geometry. The profile uses exact CRS84 longitude-latitude coordinates and does not claim an OGC API Features service." + "notes": "Relay V2 emits RFC 7946-shaped Point Features and FeatureCollections only when the selected access profile discloses the primary geometry. The profile uses exact CRS84 longitude-latitude coordinates and does not claim an OGC API Features service." }, { "id": "json-fg", @@ -228,16 +228,16 @@ ], "evidence_docs": [ { - "label": "Relay V2 spatial response acceptance tests", - "url": "https://github.com/registrystack/registry-stack/blob/545efaf9597647744ada29fdb43759a4d1fac996/crates/registry-relay-v2/tests/acceptance_http.rs" + "label": "Relay V2 governed SQLite tutorial", + "url": "/tutorials/publish-governed-sqlite-registry/" }, { - "label": "Relay V2 generated GeoJSON schema tests", - "url": "https://github.com/registrystack/registry-stack/blob/545efaf9597647744ada29fdb43759a4d1fac996/crates/registry-relay-v2/src/artifacts.rs" + "label": "Relay V2 authoring guide", + "url": "/configure/relay/" } ], "last_checked": "2026-08-10", - "notes": "Relay V2 adds the JSON-FG Core and Types-Schemas identifiers plus a governed feature type to the same Point representation. It does not implement extended JSON-FG geometries or a generic feature API." + "notes": "Relay V2 adds the JSON-FG Core and Types-Schemas identifiers plus a governed feature type to the same Point wire format. It does not implement extended JSON-FG geometries or a generic feature API." }, { "id": "openapi", diff --git a/docs/site/src/data/standards.yaml b/docs/site/src/data/standards.yaml index 3b0931f11..9b8c715d4 100644 --- a/docs/site/src/data/standards.yaml +++ b/docs/site/src/data/standards.yaml @@ -139,12 +139,12 @@ - Relay V2 governed Point responses - Relay V2 bounded Point collection responses evidence_docs: - - label: Relay V2 spatial response acceptance tests - url: https://github.com/registrystack/registry-stack/blob/545efaf9597647744ada29fdb43759a4d1fac996/crates/registry-relay-v2/tests/acceptance_http.rs - - label: Relay V2 generated GeoJSON schema tests - url: https://github.com/registrystack/registry-stack/blob/545efaf9597647744ada29fdb43759a4d1fac996/crates/registry-relay-v2/src/artifacts.rs + - label: Relay V2 governed SQLite tutorial + url: /tutorials/publish-governed-sqlite-registry/ + - label: Relay V2 authoring guide + url: /configure/relay/ last_checked: 2026-08-10 - notes: Relay V2 emits RFC 7946-shaped Point Features and FeatureCollections only when the selected governed representation discloses the primary geometry. The profile uses exact CRS84 longitude-latitude coordinates and does not claim an OGC API Features service. + notes: Relay V2 emits RFC 7946-shaped Point Features and FeatureCollections only when the selected access profile discloses the primary geometry. The profile uses exact CRS84 longitude-latitude coordinates and does not claim an OGC API Features service. - id: json-fg name: OGC Features and Geometries JSON standards_body: Open Geospatial Consortium @@ -158,12 +158,12 @@ surfaces: - Relay V2 governed JSON-FG Point responses evidence_docs: - - label: Relay V2 spatial response acceptance tests - url: https://github.com/registrystack/registry-stack/blob/545efaf9597647744ada29fdb43759a4d1fac996/crates/registry-relay-v2/tests/acceptance_http.rs - - label: Relay V2 generated GeoJSON schema tests - url: https://github.com/registrystack/registry-stack/blob/545efaf9597647744ada29fdb43759a4d1fac996/crates/registry-relay-v2/src/artifacts.rs + - label: Relay V2 governed SQLite tutorial + url: /tutorials/publish-governed-sqlite-registry/ + - label: Relay V2 authoring guide + url: /configure/relay/ last_checked: 2026-08-10 - notes: Relay V2 adds the JSON-FG Core and Types-Schemas identifiers plus a governed feature type to the same Point representation. It does not implement extended JSON-FG geometries or a generic feature API. + notes: Relay V2 adds the JSON-FG Core and Types-Schemas identifiers plus a governed feature type to the same Point wire format. It does not implement extended JSON-FG geometries or a generic feature API. - id: openapi name: OpenAPI standards_body: OpenAPI Initiative From 78c6b24e48f2d387da3ae626a4e57d24153011f6 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 10 Aug 2026 18:19:03 +0700 Subject: [PATCH 22/24] fix(relay): align generated capability contracts Signed-off-by: Jeremi Joslin --- crates/registry-relay-v2/src/api.rs | 195 +++++++++++++++--- crates/registry-relay-v2/src/artifacts.rs | 133 +++++++++++- .../registry-relay-v2/src/identification.rs | 21 +- crates/registry-relay-v2/src/lib.rs | 3 + crates/registry-relayctl/src/lib.rs | 2 +- products/relay-v2/IMPLEMENTATION.md | 6 +- .../contracts/generated-baselines.yaml | 54 ++--- .../relay-v2/scripts/test_adopter_workflow.py | 15 +- .../scripts/test_adopter_workflow_openapi.py | 13 +- 9 files changed, 355 insertions(+), 87 deletions(-) diff --git a/crates/registry-relay-v2/src/api.rs b/crates/registry-relay-v2/src/api.rs index 6b3defa93..257028dd7 100644 --- a/crates/registry-relay-v2/src/api.rs +++ b/crates/registry-relay-v2/src/api.rs @@ -36,11 +36,10 @@ use crate::problem::{ProblemCode, TraceContext}; use crate::server::{uri_within_bound, RelayService}; use crate::sqlite_runtime::{OperationQuery, PointBbox, SourceRevision, SqliteRuntimeError}; use crate::transform; +use crate::{API_BINDING_NAME, API_BINDING_VERSION}; const PRODUCT_NAME: &str = "Registry Relay"; const PRODUCT_VERSION: &str = "2"; -const API_BINDING_NAME: &str = "registry-relay-http"; -const API_BINDING_VERSION: &str = "v2"; const METADATA_DEFAULT_PAGE_SIZE: usize = 50; const METADATA_MAXIMUM_PAGE_SIZE: usize = 100; const MAXIMUM_SERIALIZED_RESPONSE_BYTES: usize = 8 * 1024 * 1024; @@ -2736,38 +2735,101 @@ fn negotiate( resource: &CompiledResource, access_profile: &CompiledAccessProfile, ) -> Result { - let Some(value) = headers.get(ACCEPT) else { - return Ok(ResponseFormat::Json); - }; - let value = value.to_str().map_err(|_| ProblemCode::UnsupportedFormat)?; - let mut json = false; - let mut json_ld = false; - let mut geojson = false; - for item in value.split(',') { - let mut parts = item.trim().split(';'); - let media = parts.next().unwrap_or_default().trim(); - let refused = parts.any(|parameter| parameter.trim() == "q=0"); - if refused { - continue; - } - match media { - "application/json" | "application/*" | "*/*" => json = true, - "application/ld+json" => json_ld = true, - "application/geo+json" => geojson = true, - _ => {} + let mut preferences = [None; 3]; + let mut supplied = false; + for value in headers.get_all(ACCEPT) { + supplied = true; + let value = value.to_str().map_err(|_| ProblemCode::UnsupportedFormat)?; + for item in value.split(',') { + let mut parts = item.trim().split(';'); + let media = parts.next().unwrap_or_default().trim(); + let (targets, specificity) = if media.eq_ignore_ascii_case("application/json") { + (&[0][..], 2) + } else if media.eq_ignore_ascii_case("application/ld+json") { + (&[1][..], 2) + } else if media.eq_ignore_ascii_case("application/geo+json") { + (&[2][..], 2) + } else if media.eq_ignore_ascii_case("application/*") { + (&[0, 1, 2][..], 1) + } else if media == "*/*" { + (&[0, 1, 2][..], 0) + } else { + continue; + }; + let quality = accept_quality(parts)?; + for target in targets { + update_accept_preference(&mut preferences[*target], specificity, quality); + } } } - if json_ld { + if !supplied { + return Ok(ResponseFormat::Json); + } + let json = preferences[0].map_or(0, |(_, quality)| quality); + let json_ld = preferences[1].map_or(0, |(_, quality)| quality); + let geojson = preferences[2].map_or(0, |(_, quality)| quality); + let geojson = if supports_geojson(resource, access_profile) { + geojson + } else { + 0 + }; + let preferred = json.max(json_ld).max(geojson); + if preferred == 0 { + Err(ProblemCode::UnsupportedFormat) + } else if json == preferred { + Ok(ResponseFormat::Json) + } else if json_ld == preferred { Ok(ResponseFormat::JsonLd) - } else if geojson && supports_geojson(resource, access_profile) { + } else if geojson == preferred { Ok(ResponseFormat::GeoJson(GeoJsonProfile::Rfc7946)) - } else if json { - Ok(ResponseFormat::Json) } else { Err(ProblemCode::UnsupportedFormat) } } +fn update_accept_preference(preference: &mut Option<(u8, u16)>, specificity: u8, quality: u16) { + match preference { + Some((current_specificity, _)) if *current_specificity > specificity => {} + Some((current_specificity, current_quality)) if *current_specificity == specificity => { + *current_quality = (*current_quality).max(quality); + } + _ => *preference = Some((specificity, quality)), + } +} + +fn accept_quality<'a>(parameters: impl Iterator) -> Result { + let mut quality = None; + for parameter in parameters { + let Some((name, value)) = parameter.trim().split_once('=') else { + continue; + }; + if !name.trim().eq_ignore_ascii_case("q") { + continue; + } + if quality.is_some() { + return Err(ProblemCode::UnsupportedFormat); + } + quality = Some(parse_quality(value.trim()).ok_or(ProblemCode::UnsupportedFormat)?); + } + Ok(quality.unwrap_or(1000)) +} + +fn parse_quality(value: &str) -> Option { + let (whole, fraction) = value.split_once('.').unwrap_or((value, "")); + if fraction.len() > 3 || !fraction.bytes().all(|byte| byte.is_ascii_digit()) { + return None; + } + match whole { + "0" => { + let mut digits = fraction.as_bytes().to_vec(); + digits.resize(3, b'0'); + std::str::from_utf8(&digits).ok()?.parse().ok() + } + "1" if fraction.bytes().all(|byte| byte == b'0') => Some(1000), + _ => None, + } +} + fn rejects_caller_purpose(headers: &HeaderMap) -> bool { headers.contains_key("purpose") || headers.contains_key("x-purpose") } @@ -3135,8 +3197,8 @@ fn capability( "pattern": operation_pattern(operation.pattern), "resourceIdentifier": resource.id, "operationIdentifier": operation.identifier, - "accessProfile": access_profile.id, - "defaultAccessProfile": operation.default_access_profile == access_profile.id, + "accessProfileIdentifier": access_profile.id, + "isDefault": operation.default_access_profile == access_profile.id, "disclosureProfile": access_profile.disclosure_profile, "schemaReference": access_profile.schema_reference, "semanticModelReference": access_profile.semantic_model_reference, @@ -3356,6 +3418,8 @@ fn if_none_match(headers: &HeaderMap, etag: &str) -> bool { #[cfg(test)] mod tests { use super::*; + use crate::compiler::{compile_contract_with_governed_files, tests as compiler_tests}; + use crate::model::CompileProfile; #[test] fn cursor_order_values_must_be_present_non_null_supported_scalars() { @@ -3422,4 +3486,81 @@ mod tests { None ); } + + #[test] + fn accept_quality_is_strict_and_every_zero_spelling_is_refused() { + for value in ["0", "0.", "0.0", "0.00", "0.000"] { + assert_eq!(parse_quality(value), Some(0)); + } + assert_eq!(parse_quality("0.125"), Some(125)); + assert_eq!(parse_quality("1.000"), Some(1000)); + for value in ["", ".0", "00", "0.0000", "1.001", "2", "NaN"] { + assert_eq!(parse_quality(value), None, "{value}"); + } + assert_eq!( + accept_quality([" q=0.0"].into_iter()).expect("valid quality"), + 0 + ); + assert_eq!( + accept_quality(["q=0.5", "q=0.4"].into_iter()), + Err(ProblemCode::UnsupportedFormat) + ); + } + + #[test] + fn negotiation_honors_quality_zero_weighting_and_media_type_case() { + let contract = compiler_tests::spatial_contract(true); + let registry = compile_contract_with_governed_files( + &contract, + &[compiler_tests::spatial_observed_schema()], + CompileProfile::Production, + &compiler_tests::governed_files_for(&contract), + ) + .expect("spatial contract compiles"); + let resource = ®istry.resources[0]; + let access_profile = &resource.operations[0].access_profiles[0]; + let mut headers = HeaderMap::new(); + headers.insert( + ACCEPT, + HeaderValue::from_static("Application/Geo+Json;q=0.0, application/json;q=1"), + ); + assert_eq!( + negotiate(&headers, resource, access_profile).expect("JSON remains acceptable"), + ResponseFormat::Json + ); + headers.insert( + ACCEPT, + HeaderValue::from_static("application/json;q=0.5, Application/Geo+Json;q=0.9"), + ); + assert_eq!( + negotiate(&headers, resource, access_profile).expect("GeoJSON is preferred"), + ResponseFormat::GeoJson(GeoJsonProfile::Rfc7946) + ); + headers.insert( + ACCEPT, + HeaderValue::from_static("application/json;q=0, */*;q=1"), + ); + assert_eq!( + negotiate(&headers, resource, access_profile) + .expect("the exact JSON refusal overrides the wildcard"), + ResponseFormat::JsonLd + ); + headers.remove(ACCEPT); + headers.append(ACCEPT, HeaderValue::from_static("application/json;q=0.4")); + headers.append( + ACCEPT, + HeaderValue::from_static("Application/Geo+Json;q=0.8"), + ); + assert_eq!( + negotiate(&headers, resource, access_profile) + .expect("repeated Accept fields form one media range list"), + ResponseFormat::GeoJson(GeoJsonProfile::Rfc7946) + ); + headers.insert(ACCEPT, HeaderValue::from_static("*/*")); + assert_eq!( + negotiate(&headers, resource, access_profile) + .expect("the practical wildcard default remains JSON"), + ResponseFormat::Json + ); + } } diff --git a/crates/registry-relay-v2/src/artifacts.rs b/crates/registry-relay-v2/src/artifacts.rs index 5320fe480..236ab314c 100644 --- a/crates/registry-relay-v2/src/artifacts.rs +++ b/crates/registry-relay-v2/src/artifacts.rs @@ -175,7 +175,7 @@ pub fn generate_artifacts(registry: &CompiledRegistry) -> Result Value { "x-registry-family": "consultation", "x-registry-pattern": consultation_pattern(operation.pattern), "x-registry-access-profiles": visible_access_profiles.iter().map(|access_profile| json!({ - "identifier": access_profile.id, - "default": operation.default_access_profile == access_profile.id, + "accessProfileIdentifier": access_profile.id, + "isDefault": operation.default_access_profile == access_profile.id, "disclosureProfile": access_profile.disclosure_profile, "processingHandling": access_profile.processing_handling, "disclosureHandling": access_profile.disclosure_handling, @@ -800,7 +800,7 @@ fn openapi(registry: &CompiledRegistry, public_only: bool) -> Value { .filter_map(|access_profile| match &access_profile.access { CompiledAccess::Public => None, CompiledAccess::Protected { scope, .. } => Some(json!({ - "accessProfile": access_profile.id, + "accessProfileIdentifier": access_profile.id, "scope": scope, })), }) @@ -908,7 +908,7 @@ fn operation_response_schema( json!({"$ref": access_profiles[0].schema_reference}) } else { json!({ - "oneOf": access_profiles.iter().map(|access_profile| { + "anyOf": access_profiles.iter().map(|access_profile| { json!({"$ref": access_profile.schema_reference}) }).collect::>() }) @@ -945,12 +945,21 @@ fn operation_response_content( access_profiles: &[&crate::model::CompiledAccessProfile], ) -> Value { let ordinary = operation_response_schema(operation, access_profiles); + let json_ld = access_profiles + .iter() + .map(|access_profile| json_ld_response_schema(operation, access_profile)) + .collect::>(); + let json_ld = if json_ld.len() == 1 { + json_ld.into_iter().next().expect("one JSON-LD schema") + } else { + json!({"anyOf": json_ld}) + }; let mut content = Map::from_iter([ ( "application/json".into(), json!({"schema": ordinary.clone()}), ), - ("application/ld+json".into(), json!({"schema": ordinary})), + ("application/ld+json".into(), json!({"schema": json_ld})), ]); let spatial = access_profiles .iter() @@ -963,13 +972,57 @@ fn operation_response_content( let schema = if spatial.len() == 1 { spatial.into_iter().next().expect("one spatial schema") } else { - json!({"oneOf": spatial}) + json!({"anyOf": spatial}) }; content.insert("application/geo+json".into(), json!({"schema": schema})); } Value::Object(content) } +fn json_ld_response_schema( + operation: &CompiledOperation, + access_profile: &crate::model::CompiledAccessProfile, +) -> Value { + let record = json_ld_record_schema(access_profile); + match &operation.kind { + OperationKind::List | OperationKind::Search { .. } => json!({ + "type": "object", + "additionalProperties": false, + "required": ["@context", "items", "pageInfo", "meta"], + "properties": { + "@context": {"type": "string", "enum": [access_profile.context_reference]}, + "items": {"type": "array", "items": record}, + "pageInfo": { + "type": "object", + "additionalProperties": false, + "required": ["nextCursor"], + "properties": {"nextCursor": {"type": ["string", "null"]}} + }, + "meta": {"type": "object"} + } + }), + OperationKind::Read | OperationKind::Lookup { .. } => json!({ + "type": "object", + "additionalProperties": false, + "required": ["@context", "data", "meta"], + "properties": { + "@context": {"type": "string", "enum": [access_profile.context_reference]}, + "data": record, + "meta": {"type": "object"} + } + }), + } +} + +fn json_ld_record_schema(access_profile: &crate::model::CompiledAccessProfile) -> Value { + json!({ + "allOf": [ + {"$ref": access_profile.schema_reference}, + {"type": "object", "required": ["@id", "@type"]} + ] + }) +} + fn geojson_response_schema( registry: &CompiledRegistry, operation: &CompiledOperation, @@ -1218,10 +1271,10 @@ fn capability_inventory( OperationKind::Search { .. } => "search", }; Some(json!({ - "resource": resource.id, + "resourceIdentifier": resource.id, "operationIdentifier": operation.identifier, "accessProfileIdentifier": access_profile.id, - "defaultAccessProfile": operation.default_access_profile == access_profile.id, + "isDefault": operation.default_access_profile == access_profile.id, "family": "consultation", "pattern": pattern, "queryKind": match &operation.kind { @@ -1251,7 +1304,7 @@ fn capability_inventory( "registryIdentifier": registry.registry_identifier, "authorityIdentifier": registry.authority_identifier, "contractRevision": registry.contract_revision, - "apiBinding": {"name": "registry-relay", "version": "v2alpha1"}, + "apiBinding": {"name": crate::API_BINDING_NAME, "version": crate::API_BINDING_VERSION}, "alignmentTargets": registry.alignment_targets, "metadataVisibility": registry.metadata_visibility, "capabilities": capabilities, @@ -1428,6 +1481,32 @@ mod tests { assert!(full["components"]["securitySchemes"] .get("oauth2") .is_none()); + let read_content = &full["paths"]["/v2/resources/record/records/{recordIdentifier}"]["get"] + ["responses"]["200"]["content"]; + let json_schema = &read_content["application/json"]["schema"]; + let json_ld_schema = &read_content["application/ld+json"]["schema"]; + assert!(json_schema["properties"].get("@context").is_none()); + assert_eq!(json_ld_schema["properties"]["@context"]["type"], "string"); + assert!(json_ld_schema["required"] + .as_array() + .expect("JSON-LD response required members") + .contains(&json!("@context"))); + let json_ld_record = &json_ld_schema["properties"]["data"]; + let read_access_profile = registry.resources[0] + .operations + .iter() + .find(|operation| matches!(&operation.kind, OperationKind::Read)) + .and_then(|operation| operation.access_profiles.first()) + .expect("compiled read access profile"); + assert_eq!( + json_ld_record["allOf"][0]["$ref"], + read_access_profile.schema_reference + ); + let json_ld_required = json_ld_record["allOf"][1]["required"] + .as_array() + .expect("JSON-LD Record required members"); + assert!(json_ld_required.contains(&json!("@id"))); + assert!(json_ld_required.contains(&json!("@type"))); } #[test] @@ -1505,6 +1584,40 @@ mod tests { } } + #[test] + fn response_schema_unions_allow_access_profiles_with_the_same_disclosure_shape() { + let registry = spatial_registry(CompiledAccess::Public); + let resource = ®istry.resources[0]; + let operation = &resource.operations[0]; + let first = &operation.access_profiles[0]; + let mut equivalent = first.clone(); + equivalent.id = "equivalent-profile".into(); + let content = + operation_response_content(®istry, operation, resource, &[first, &equivalent]); + + assert_eq!( + content["application/json"]["schema"]["properties"]["items"]["items"]["anyOf"] + .as_array() + .expect("ordinary response uses an inclusive schema union") + .len(), + 2 + ); + assert_eq!( + content["application/ld+json"]["schema"]["anyOf"] + .as_array() + .expect("JSON-LD response uses an inclusive schema union") + .len(), + 2 + ); + assert_eq!( + content["application/geo+json"]["schema"]["anyOf"] + .as_array() + .expect("GeoJSON response uses an inclusive schema union") + .len(), + 2 + ); + } + #[test] fn spatial_artifacts_are_deterministic_bounded_and_carrier_free() { let registry = spatial_registry(CompiledAccess::Public); diff --git a/crates/registry-relay-v2/src/identification.rs b/crates/registry-relay-v2/src/identification.rs index 892dc05be..a212305c2 100644 --- a/crates/registry-relay-v2/src/identification.rs +++ b/crates/registry-relay-v2/src/identification.rs @@ -504,8 +504,8 @@ pub struct SelectionExplanation { #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] #[serde(deny_unknown_fields, rename_all = "camelCase")] pub struct AccessProfileExplanation { - pub id: String, - pub default: bool, + pub access_profile_identifier: String, + pub is_default: bool, pub access: AccessPolicyExplanation, pub processing: ProcessingExplanation, pub disclosure: DisclosureExplanation, @@ -610,8 +610,8 @@ pub fn operation_explanation( .access_profiles .iter() .map(|access_profile| AccessProfileExplanation { - id: access_profile.id.clone(), - default: access_profile.id == operation.default_access_profile, + access_profile_identifier: access_profile.id.clone(), + is_default: access_profile.id == operation.default_access_profile, access: access_policy_explanation(&access_profile.access), processing: ProcessingExplanation { source_columns: processed_columns(operation, access_profile), @@ -631,7 +631,10 @@ pub fn operation_explanation( }, }) .collect::>(); - access_profiles.sort_by(|left, right| left.id.cmp(&right.id)); + access_profiles.sort_by(|left, right| { + left.access_profile_identifier + .cmp(&right.access_profile_identifier) + }); let (method, path) = operation_http_binding(resource, operation); OperationExplanationEntry { resource_identifier: resource.id.clone(), @@ -781,8 +784,8 @@ pub fn render_operation_explanation_text(report: &OperationExplanation) -> Strin let _ = writeln!( output, " access profile: {}{}", - access_profile.id, - if access_profile.default { + access_profile.access_profile_identifier, + if access_profile.is_default { " (default)" } else { "" @@ -2627,7 +2630,7 @@ mod tests { let access_profile = operation .access_profiles .iter() - .find(|profile| profile.id == "public") + .find(|profile| profile.access_profile_identifier == "public") .expect("public access profile"); assert_eq!(access_profile.wire_formats.len(), 3); assert!(matches!( @@ -2652,7 +2655,7 @@ mod tests { let hidden_geometry = operation .access_profiles .iter() - .find(|profile| profile.id == "hidden-geometry") + .find(|profile| profile.access_profile_identifier == "hidden-geometry") .expect("hidden-geometry access profile"); assert!(hidden_geometry .processing diff --git a/crates/registry-relay-v2/src/lib.rs b/crates/registry-relay-v2/src/lib.rs index ac5119a2c..ddcf680f2 100644 --- a/crates/registry-relay-v2/src/lib.rs +++ b/crates/registry-relay-v2/src/lib.rs @@ -1,6 +1,9 @@ // SPDX-License-Identifier: Apache-2.0 //! Relay V2's shared governed-contract compiler and runtime kernel. +pub(crate) const API_BINDING_NAME: &str = "registry-relay-http"; +pub(crate) const API_BINDING_VERSION: &str = "v2"; + pub mod api; pub mod artifacts; pub mod audit; diff --git a/crates/registry-relayctl/src/lib.rs b/crates/registry-relayctl/src/lib.rs index 427cfea6e..d86e78fef 100644 --- a/crates/registry-relayctl/src/lib.rs +++ b/crates/registry-relayctl/src/lib.rs @@ -103,7 +103,7 @@ struct TestArgs { #[arg(value_name = "PROJECT")] project: std::path::PathBuf, - /// Run one exact fixture identifier. + /// Run one selected fixture and its declared prerequisites. #[arg(long, value_name = "IDENTIFIER")] fixture: Option, } diff --git a/products/relay-v2/IMPLEMENTATION.md b/products/relay-v2/IMPLEMENTATION.md index a3a33fc1b..951c38896 100644 --- a/products/relay-v2/IMPLEMENTATION.md +++ b/products/relay-v2/IMPLEMENTATION.md @@ -380,8 +380,10 @@ GET /v2/artifacts/{artifactIdentifier} `GET /v2` returns the closed service document `{registryIdentifier, name, authority, operator, authoritativeScope, product, apiBinding, alignmentTargets, capabilities, links}`. `product` and `apiBinding` -each contain `name` and `version`; `capabilities` contains only visible -`{family, pattern, resourceIdentifier, operationIdentifier, href}` entries. +each contain `name` and `version`; each visible capability identifies its family, pattern, +resource identifier, operation, access profile, default posture, disclosure and semantic references, wire +formats, optional bounded spatial query, and route. Capability, OpenAPI, and explanation surfaces +use `accessProfileIdentifier` and `isDefault` for the same finite profile identity. `operator` is present and nullable when the Registry has not named one. `GET /v2/resources` returns `{items, pageInfo, meta}` where each item contains diff --git a/products/relay-v2/contracts/generated-baselines.yaml b/products/relay-v2/contracts/generated-baselines.yaml index 10c3dfed6..8e0a817c6 100644 --- a/products/relay-v2/contracts/generated-baselines.yaml +++ b/products/relay-v2/contracts/generated-baselines.yaml @@ -2,7 +2,7 @@ schemaVersion: relay.registrystack.org/generated-baselines/v1alpha1 product: relay-v2 projects: social-assistance: - packageRevision: sha256:63217db19dcc9c53240a60137bba4c88b00e880c86e7a3a6f4dd2c7cb798cf58 + packageRevision: sha256:e54b43d7f59419c3231659a5a802ab95af856c435379797cfd9aa0b9fd42b577 contractRevision: sha256:9011885e752b26128bf6c98798e5fd674624ae04912cd37c7597311e8a805b1e sourceSchemaFingerprints: assistance: sha256:936a90a03d06be67a76226d6999a830c04f6604a3ff8b340a62fdd378d8c6d91 @@ -12,7 +12,7 @@ projects: mediaType: application/json operationIdentifier: assistance-enrolment.lookup.by-case-and-person path: generated/artifacts/assistance-enrolment--lookup-by-case-and-person--access-profile-caseworker.capability.json - sha256: sha256:ffde4588cbf935c6ee2f2803c1f4a912e6621dac057b60fbf46236bc67e3640b + sha256: sha256:4d83f1180e557ebff56e599fe11684d4a801add1e2a0a82b68ef136216cf7202 visibility: operation-bound - accessProfileIdentifier: null id: assistance-enrolment--lookup-by-case-and-person--access-profile-caseworker-classifications @@ -61,7 +61,7 @@ projects: mediaType: application/json operationIdentifier: assistance-enrolment.lookup.by-case-and-person path: generated/artifacts/assistance-enrolment--lookup-by-case-and-person--access-profile-limited.capability.json - sha256: sha256:f12ff40c01a8edca83840cf6e865bb9b5a133cec160fed89ec68a875955bbea8 + sha256: sha256:7e985d90283cdd6a0e514f87cd1197ea8d74d31620092528272063dc05d1883e visibility: operation-bound - accessProfileIdentifier: null id: assistance-enrolment--lookup-by-case-and-person--access-profile-limited-classifications @@ -110,7 +110,7 @@ projects: mediaType: application/json operationIdentifier: null path: generated/artifacts/assistance-enrolment.classifications.json - sha256: sha256:32707dfb3d94080914c914d5bded55741758dfd4e1f2eef49c89e4376042eace + sha256: sha256:de4a4b21602b10f8b4cbdcc06d9d01f05c552259656c755accad700d4dbcb086 visibility: operator-only - accessProfileIdentifier: null id: assistance-enrolment-codelist-0 @@ -173,21 +173,21 @@ projects: mediaType: application/json operationIdentifier: null path: generated/artifacts/capabilities.full.json - sha256: sha256:4270b8f73ae1f74aec83079fe8b514e3538c0a479b821c4c8c2feef83bd0ff2f + sha256: sha256:0789a41100832281b8b357922fe76a0732620fdf8befaffdc3e4d1374ff7b12a visibility: operator-only - accessProfileIdentifier: null id: capability-inventory mediaType: application/json operationIdentifier: null path: generated/artifacts/capabilities.json - sha256: sha256:a2ca8379d9e94604b07109db19087def16b380ce977bfc90a74c3bc92e795d85 + sha256: sha256:b3862333658a891d5922836fcb080e8840f944c613c087f503bb726408eb4d05 visibility: public - accessProfileIdentifier: null id: openapi-full mediaType: application/yaml operationIdentifier: null path: generated/openapi.full.yaml - sha256: sha256:9902dae9ca28f926b9a646c027321be5bc147d2a668d910c2ab5a77d7ae04c4a + sha256: sha256:ed7f7507c8af5f8364addbf85a27b2e0b27376e03fbf2f750a7130d814f89247 visibility: operator-only - accessProfileIdentifier: null id: openapi-public @@ -252,7 +252,7 @@ projects: size: 6470 visibility: operator-only business-registry: - packageRevision: sha256:29d37d6bf8ca1d278b5cbbda0cf9bd47c1c79da0195ba5a0c767cf127b913163 + packageRevision: sha256:fcde30f79c747796e500aa5ded84705c8d6083fa33d1cfc75542a7c6469fcfea contractRevision: sha256:f72669730175ad097512fa9eda378bbbd3bbb64a859615e42d4752b277630968 sourceSchemaFingerprints: companies: sha256:dd62b98578f0fa7341eeeaaac4b34da9b79405ae067dc06e5edb004c2d4a38fe @@ -269,14 +269,14 @@ projects: mediaType: application/json operationIdentifier: null path: generated/artifacts/capabilities.full.json - sha256: sha256:84581ff4973a06efc9c383930a873cdc422595381aa278f9c01cb7eea4904abf + sha256: sha256:77ae7574056e5687a0fc4ecfa653dd517c3d426e4e6b1d2ece59e475d91c8c41 visibility: operator-only - accessProfileIdentifier: null id: capability-inventory mediaType: application/json operationIdentifier: null path: generated/artifacts/capabilities.json - sha256: sha256:3b1bb8c5dfc320a6565ae01e2d53eee1dc44b41719c5dc1d1872b3b0f46e1a3c + sha256: sha256:26bcbe794fe72b9fdece5da37361c82ed152405d003765d3acdafc3f6214a023 visibility: public - accessProfileIdentifier: null id: registered-business--list--access-profile-public-register-classifications @@ -325,7 +325,7 @@ projects: mediaType: application/json operationIdentifier: registered-business.list path: generated/artifacts/registered-business--list--access-profile-registrar.capability.json - sha256: sha256:1eeed1145edef2a16a9440a34f54e4118f041b28d24bd79243fd2483cdebd05d + sha256: sha256:ddeffc033daed72ebc796072eac864339a3ec850edc96a816eabaa000643c730 visibility: operation-bound - accessProfileIdentifier: registrar id: registered-business--list--access-profile-registrar-classifications @@ -416,7 +416,7 @@ projects: mediaType: application/json operationIdentifier: registered-business.read path: generated/artifacts/registered-business--read--access-profile-registrar.capability.json - sha256: sha256:bae49b54fbe55e83a870aad8041bc49d36d1c30a336c6c0c55916c6a84d55392 + sha256: sha256:be3cfc06111851257df4e27ae9f2b17ebb193ac8de2533ad24c0f0c092bd84de visibility: operation-bound - accessProfileIdentifier: registrar id: registered-business--read--access-profile-registrar-classifications @@ -465,7 +465,7 @@ projects: mediaType: application/json operationIdentifier: null path: generated/artifacts/registered-business.classifications.json - sha256: sha256:0eba6b9824ab9b0e21482bf49cecc6db1401073f2103df2ca57fd93b1383915a + sha256: sha256:b7a423ff392130cfb833df5e2c5829aa3c9bb4c4e6a363deca4461760e75307d visibility: operator-only - accessProfileIdentifier: null id: registered-business-codelist-0 @@ -528,7 +528,7 @@ projects: mediaType: application/json operationIdentifier: registered-premises.list path: generated/artifacts/registered-premises--list--access-profile-registrar-premises.capability.json - sha256: sha256:895f7603bb4d4bd0c69eebb00b01af0469e8a8cec36fef4e1f5d6b9a2884dd1f + sha256: sha256:b31f04d8f49fee827a9eefd3a6bbdf55f1d1233df358974b2a674726bad247ed visibility: operation-bound - accessProfileIdentifier: registrar-premises id: registered-premises--list--access-profile-registrar-premises-classifications @@ -633,7 +633,7 @@ projects: mediaType: application/json operationIdentifier: registered-premises.read path: generated/artifacts/registered-premises--read--access-profile-registrar-premises.capability.json - sha256: sha256:b0fb543ad7f96e0715ccad693cae22bf02fe1e27b67a6b5bfe7c4755924d80a2 + sha256: sha256:624bcfa21317b3fc9f6251a216ef3b936c792e4a7deeab70e4acc7487a7d5915 visibility: operation-bound - accessProfileIdentifier: registrar-premises id: registered-premises--read--access-profile-registrar-premises-classifications @@ -738,7 +738,7 @@ projects: mediaType: application/json operationIdentifier: registered-premises.search.within-bbox path: generated/artifacts/registered-premises--search-within-bbox--access-profile-registrar-premises.capability.json - sha256: sha256:75549a4599894196e026203ceb9aca253299bf4dff50db17b7f3078cd464ff6f + sha256: sha256:134ff087810e520d23ea53a90c188e4c76bda92279e072a5a45abc19172afb03 visibility: operation-bound - accessProfileIdentifier: registrar-premises id: registered-premises--search-within-bbox--access-profile-registrar-premises-classifications @@ -794,7 +794,7 @@ projects: mediaType: application/json operationIdentifier: null path: generated/artifacts/registered-premises.classifications.json - sha256: sha256:6894b5990a0ffc7e008b197fc150d6ce19440317ba211dbca5ce4417b38d7e33 + sha256: sha256:f3d43fd7530a656713444a232d0ac5517ace5b04a1235caf3a1fc5f5ddf95e49 visibility: operator-only - accessProfileIdentifier: null id: registered-premises-codelist-0 @@ -836,14 +836,14 @@ projects: mediaType: application/yaml operationIdentifier: null path: generated/openapi.full.yaml - sha256: sha256:e142704c094faeb697e26eb9195cc1593f9623147daaa94d356cdc83c810b824 + sha256: sha256:9dd9d6da2119f488237d5a43af44858269f301d2d220a6fd668ec13b05b02d7e visibility: operator-only - accessProfileIdentifier: null id: openapi-public mediaType: application/json operationIdentifier: null path: generated/openapi.public.json - sha256: sha256:e0dd38413b3ba98a80a9b42014f564733b38f8af51cf12674591af88870280e8 + sha256: sha256:6ac224660b2bb76d15b89ae8fac9abd5bdeac96e893951f4e2d5e2329e85bbda visibility: public governedFiles: - generated: false @@ -907,7 +907,7 @@ projects: size: 10965 visibility: operator-only civil-event: - packageRevision: sha256:6b7ee913278e9bca570868cd2ac3a8e7cf60886a7549438005dd49774e65087e + packageRevision: sha256:ab90a441c87cac2aa8206bb460b30a7f0466c0dcc2b6f0b719fc43c82f3c2ee1 contractRevision: sha256:011d151e19402f7c4b4bd02c10be4afa93567c5e0cc1e6db28a03a7d1560e8e0 sourceSchemaFingerprints: events: sha256:7f770d64cb19ec54caca2aa56378b13a43cd5edc206ff44b5fecc99ee9e63759 @@ -924,21 +924,21 @@ projects: mediaType: application/json operationIdentifier: null path: generated/artifacts/capabilities.full.json - sha256: sha256:7193f71e6de3878e0441490fddbd4dad1ee0135fb41cc5f7333ba039c307da03 + sha256: sha256:f6e37d3443f1dbf9bcdbe066303840445ed8e34ae74cfa13b31b4cb70cd20d69 visibility: operator-only - accessProfileIdentifier: null id: capability-inventory mediaType: application/json operationIdentifier: null path: generated/artifacts/capabilities.json - sha256: sha256:2d19a8c1ae7c66dd2832ea75df6baaea30c8eacbc523d8a9f7405393cefffdac + sha256: sha256:51c7394e621e38bff2a5213d06440b150000f94699ca9f9733f3d482988235c2 visibility: public - accessProfileIdentifier: registrar-verification id: civil-event--lookup-verify-registration--access-profile-registrar-verification-capability mediaType: application/json operationIdentifier: civil-event.lookup.verify-registration path: generated/artifacts/civil-event--lookup-verify-registration--access-profile-registrar-verification.capability.json - sha256: sha256:b88209048ded9b3dd3ce7c945604bfaa401849e4fa2c0991a33e62e5d2104f8a + sha256: sha256:81f7b8dc8bed62ce65b6e540618f2e334c6b23d0dcbe42f3fb289f5f2c93747f visibility: operation-bound - accessProfileIdentifier: null id: civil-event--lookup-verify-registration--access-profile-registrar-verification-classifications @@ -987,7 +987,7 @@ projects: mediaType: application/json operationIdentifier: civil-event.lookup.verify-registration path: generated/artifacts/civil-event--lookup-verify-registration--access-profile-supervisory.capability.json - sha256: sha256:7041d09be104c948766f0d6a699fa576de67e0f2465b00ea38aa4e86f3dd7ed1 + sha256: sha256:285492f6ee4baa35ef9540665188f55ceb07dc0148efe84d9d22a7d842ccfbea visibility: operation-bound - accessProfileIdentifier: null id: civil-event--lookup-verify-registration--access-profile-supervisory-classifications @@ -1036,7 +1036,7 @@ projects: mediaType: application/json operationIdentifier: civil-event.read path: generated/artifacts/civil-event--read--access-profile-registrar.capability.json - sha256: sha256:08bb55f264fa71075706b40c00ebb622c83a6822f4a23e991c95093a7ef1ca30 + sha256: sha256:03042beaa51ae53a06a23da5e84c5a2a164203c3085cb56daa6066db8f0eb69d visibility: operation-bound - accessProfileIdentifier: null id: civil-event--read--access-profile-registrar-classifications @@ -1085,7 +1085,7 @@ projects: mediaType: application/json operationIdentifier: null path: generated/artifacts/civil-event.classifications.json - sha256: sha256:f5bbe318289cea6113fcf05873855ca972c0e0a1796d66f2b9ec28265cdaa25d + sha256: sha256:c8c9dbacda4149f0e70cf6bd0cbc15b1876c0faf2ccca4bac0eff8d189e4c2a2 visibility: operator-only - accessProfileIdentifier: null id: civil-event-codelist-0 @@ -1148,7 +1148,7 @@ projects: mediaType: application/yaml operationIdentifier: null path: generated/openapi.full.yaml - sha256: sha256:62ed8cad2048751ccf9a3dc1141c6352a243e70e990f8d16b85b17fb4e742c60 + sha256: sha256:ed915507e41e83ffcf72f7cbf22f90073c4fd507e3834b98f0e854668d808a5e visibility: operator-only - accessProfileIdentifier: null id: openapi-public diff --git a/products/relay-v2/scripts/test_adopter_workflow.py b/products/relay-v2/scripts/test_adopter_workflow.py index 4560ecf35..ee56e077a 100755 --- a/products/relay-v2/scripts/test_adopter_workflow.py +++ b/products/relay-v2/scripts/test_adopter_workflow.py @@ -115,9 +115,11 @@ def access_profile_identifiers(operation: dict[str, Any], label: str) -> set[str raise GateFailure(f"{label} has no finite access profiles") identifiers: set[str] = set() for profile in profiles: - if not isinstance(profile, dict) or not isinstance(profile.get("identifier"), str): + if not isinstance(profile, dict) or not isinstance( + profile.get("accessProfileIdentifier"), str + ): raise GateFailure(f"{label} has a malformed access profile") - identifier = profile["identifier"] + identifier = profile["accessProfileIdentifier"] if not identifier or identifier in identifiers: raise GateFailure(f"{label} has duplicate or empty access-profile identifiers") identifiers.add(identifier) @@ -163,16 +165,17 @@ def validate_public_operation( if public.get("security") != [] or "x-registry-required-scopes" in public: raise GateFailure("public OpenAPI operation carries protected access or security") full_profiles = { - profile["identifier"]: profile + profile["accessProfileIdentifier"]: profile for profile in full["x-registry-access-profiles"] } protected_ids = { - entry.get("accessProfile") + entry.get("accessProfileIdentifier") for entry in full.get("x-registry-required-scopes", []) - if isinstance(entry, dict) and isinstance(entry.get("accessProfile"), str) + if isinstance(entry, dict) + and isinstance(entry.get("accessProfileIdentifier"), str) } for profile in public["x-registry-access-profiles"]: - identifier = profile["identifier"] + identifier = profile["accessProfileIdentifier"] if identifier in protected_ids: raise GateFailure("public OpenAPI exposes a protected access profile") if profile != full_profiles[identifier]: diff --git a/products/relay-v2/scripts/test_adopter_workflow_openapi.py b/products/relay-v2/scripts/test_adopter_workflow_openapi.py index 6f0cdf87d..524691407 100644 --- a/products/relay-v2/scripts/test_adopter_workflow_openapi.py +++ b/products/relay-v2/scripts/test_adopter_workflow_openapi.py @@ -20,8 +20,8 @@ class PublicOpenApiProjectionTests(unittest.TestCase): def test_rejects_a_protected_access_profile_in_public_output(self) -> None: public_profile = { - "identifier": "public-register", - "default": True, + "accessProfileIdentifier": "public-register", + "isDefault": True, "disclosureProfile": "public-register", "processingHandling": "public", "disclosureHandling": "public", @@ -32,8 +32,8 @@ def test_rejects_a_protected_access_profile_in_public_output(self) -> None: } protected_profile = { **public_profile, - "identifier": "registrar", - "default": False, + "accessProfileIdentifier": "registrar", + "isDefault": False, "disclosureProfile": "registrar", "processingHandling": "confidential", "disclosureHandling": "confidential", @@ -46,7 +46,10 @@ def test_rejects_a_protected_access_profile_in_public_output(self) -> None: "security": [{}, {"bearerAuth": []}], "x-registry-access-profiles": [public_profile, protected_profile], "x-registry-required-scopes": [ - {"accessProfile": "registrar", "scope": "registry:business:read-registrar"} + { + "accessProfileIdentifier": "registrar", + "scope": "registry:business:read-registrar", + } ], } public = { From e002601fa8bc626a77e66e5adb32fbfd9b465173 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 10 Aug 2026 18:19:03 +0700 Subject: [PATCH 23/24] docs(relay): clarify fixture prerequisite execution Signed-off-by: Jeremi Joslin --- docs/site/src/content/docs/configure/relay.mdx | 4 ++-- .../docs/tutorials/publish-governed-sqlite-registry.mdx | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/site/src/content/docs/configure/relay.mdx b/docs/site/src/content/docs/configure/relay.mdx index 7f802a6c5..19c794019 100644 --- a/docs/site/src/content/docs/configure/relay.mdx +++ b/docs/site/src/content/docs/configure/relay.mdx @@ -205,8 +205,8 @@ geometry and both carrier columns must have effective `privacy: non-personal` cl Relay refuses non-finite values, coordinates outside CRS84, decreasing bounds, antimeridian crossing, and spans larger than the authored limits. It does not expose a spatial expression language, reprojection, -joins, or dynamic SQLite extensions. The [business acceptance contract](https://github.com/registrystack/registry-stack/blob/main/products/relay-v2/acceptance/business-registry/registry.yaml) -is the executable configuration example. +joins, or dynamic SQLite extensions. The [synthetic Relay tutorial](../../tutorials/publish-governed-sqlite-registry/) +executes the tracked business-registry example. ## Account for processing and disclosure diff --git a/docs/site/src/content/docs/tutorials/publish-governed-sqlite-registry.mdx b/docs/site/src/content/docs/tutorials/publish-governed-sqlite-registry.mdx index 10ec9f650..1e3552951 100644 --- a/docs/site/src/content/docs/tutorials/publish-governed-sqlite-registry.mdx +++ b/docs/site/src/content/docs/tutorials/publish-governed-sqlite-registry.mdx @@ -262,7 +262,8 @@ bounded bbox query. Replay its JSON-FG fixture: relayctl test "$project" --fixture premises-feature-collection-jsonfg ``` -The report contains one successful step: +The report first runs `premises-first-page`, the declared record-equivalence prerequisite, then the +selected JSON-FG step. Both pass. The selected step is: ```json { From 15ba97b1e3c8c9a10054954864c7864bf96a3b07 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 10 Aug 2026 18:30:19 +0700 Subject: [PATCH 24/24] fix(relay): reserve spatial query parameters Signed-off-by: Jeremi Joslin --- crates/registry-relay-v2/src/compiler.rs | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/crates/registry-relay-v2/src/compiler.rs b/crates/registry-relay-v2/src/compiler.rs index d213623ee..486c28eb4 100644 --- a/crates/registry-relay-v2/src/compiler.rs +++ b/crates/registry-relay-v2/src/compiler.rs @@ -27,12 +27,13 @@ use crate::model::{ }; const API_VERSION: &str = "relay.registrystack.org/v2alpha1"; -const RESERVED_PARAMETERS: [&str; 5] = [ +const RESERVED_PARAMETERS: [&str; 6] = [ "pageSize", "cursor", "fields", "accessProfile", "formatProfile", + "bbox", ]; const MAXIMUM_RESOURCES: usize = 128; const MAXIMUM_PROPERTIES_PER_RESOURCE: usize = 128; @@ -3809,6 +3810,26 @@ pub(crate) mod tests { ); } + #[test] + fn bbox_is_reserved_for_named_spatial_search_not_list_filters() { + let yaml = valid_contract().replace( + "read:\n defaultAccessProfile: public\n accessProfiles:\n public: {access: public, disclosureProfile: public}", + "list:\n defaultAccessProfile: public\n accessProfiles:\n public: {access: public, disclosureProfile: public}\n filters:\n - {name: bbox, property: name, type: string}\n allowUnfiltered: false\n orderBy: [name]\n pagination: {defaultPageSize: 1, maximumPageSize: 10}", + ); + let contract = RegistryContract::parse_yaml(&yaml).expect("strict list contract"); + let report = compile_contract_with_governed_files( + &contract, + &[observed_schema()], + CompileProfile::Production, + &governed_files(), + ) + .expect_err("the named-search bbox parameter cannot be a list filter"); + assert!(report.diagnostics.iter().any(|diagnostic| { + diagnostic.code == "list.filter_name_invalid" + && diagnostic.location == "resources[0].operations.list.filters[0].name" + })); + } + #[test] fn sqlite_view_nullable_metadata_does_not_override_required_order_contract() { let yaml = valid_contract()