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..b8f9ce188 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) }} 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..7cac99985 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", ] @@ -5601,6 +5673,7 @@ dependencies = [ "registry-platform-httputil", "registry-platform-oidc", "registry-platform-sdjwt", + "registry-platform-sqlite", "reqwest 0.12.28", "rhai", "rusqlite", @@ -6077,6 +6150,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 +6268,65 @@ dependencies = [ "zip", ] +[[package]] +name = "registry-relay-v2" +version = "0.18.0" +dependencies = [ + "async-trait", + "axum", + "base64", + "bytes", + "chacha20poly1305", + "chrono", + "clap", + "futures", + "getrandom 0.4.3", + "hex", + "hmac 0.13.0", + "http", + "jsonschema 0.18.3", + "jsonwebtoken", + "oxjsonld", + "registry-platform-audit", + "registry-platform-authcommon", + "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" @@ -8039,6 +8184,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 51f0b4ff2..1f2dedd6f 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" } @@ -86,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-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 } 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..aadf315d4 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,59 @@ 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_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() + .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 +344,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 +377,11 @@ 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 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 @@ -498,50 +397,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 +418,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 +427,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 +554,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 +584,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 +592,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 +650,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) -} - -/// 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), - } - } + Ok(value.clone()) } -/// 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 +862,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 } @@ -1683,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 ( @@ -1733,8 +1187,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 +1277,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"); @@ -1979,15 +1422,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 +1563,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 +1950,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..29e978393 --- /dev/null +++ b/crates/registry-platform-sqlite/src/capture.rs @@ -0,0 +1,321 @@ +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}; + +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, None)?; + 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.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, 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)); + } + 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, + deadline: Option, +) -> Result<(String, FileIdentity), SqliteError> { + ensure_before_deadline(deadline)?; + 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 { + ensure_before_deadline(deadline)?; + 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))?; + } + ensure_before_deadline(deadline)?; + 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 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); + 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..232358325 --- /dev/null +++ b/crates/registry-platform-sqlite/src/statement.rs @@ -0,0 +1,1514 @@ +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, +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +enum ResponseBudgetAccounting { + SerializedResult, + TextValuesOnly, +} + +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(), + } + } + 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)] +struct BoundParameter { + index: usize, + name: String, +} + +#[derive(Debug)] +struct CompiledPlan { + sql: String, + columns: Vec, + parameters: Vec, + 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. +pub struct ReadOnlyStatement { + profile: DatabaseProfile, + plan: Arc, + connections: Arc>>, + concurrency: Arc, +} + +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 { + 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, + response_budget_accounting, + }), + 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 { + 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 profile = self.profile.clone(); + let execution = tokio::task::spawn_blocking(move || { + let execution = + execute_on_connection(&profile, &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 + .map_err(|_| SqliteError::new(ErrorKind::Timeout))? + .map_err(|_| SqliteError::new(ErrorKind::WorkerUnavailable))??; + 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 { + 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 execution = + execute_on_connection(&self.profile, &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); + } + let (rows, schema_fingerprint) = execution.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(()) +} + +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), + reusable: false, + }; + } + 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() { + execution.outcome = Err(error); + } + execution.reusable = false; + } + } + if let Err(error) = profile.verify_execution_binding(deadline) { + 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. +#[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, +) -> 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.clone()), + }; + ConnectionExecution { + outcome, + reusable: cleaned.is_ok(), + } +} + +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(); + let mut response_bytes = 0_usize; + 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, + 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)); + } + 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)) +} + +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 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>) + .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)) + } +} + +fn verify_schema_at_open( + connection: &Connection, + binding: Option<&SchemaBinding>, + limits: &StatementLimits, +) -> Result<(), SqliteError> { + let Some(binding) = binding else { + return Ok(()); + }; + let deadline = deadline(limits.timeout)?; + 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), + } +} + +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() { + 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)?; + 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) +} + +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::*; + + 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 _; + + 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 + ); + } + } + } + + #[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(); + 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( + "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..2a7474476 --- /dev/null +++ b/crates/registry-platform-sqlite/tests/kernel.rs @@ -0,0 +1,557 @@ +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(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(); + 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:")); +} + +#[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(); + 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_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 < 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; + 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); + 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_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 < 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(25); + 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 + )); + 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")] +#[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..d86082fd1 --- /dev/null +++ b/crates/registry-relay-v2/Cargo.toml @@ -0,0 +1,73 @@ +[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 +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 +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 +jsonschema.workspace = true +oxjsonld = "0.2.5" +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/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 new file mode 100644 index 000000000..257028dd7 --- /dev/null +++ b/crates/registry-relay-v2/src/api.rs @@ -0,0 +1,3566 @@ +// 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, LINK, 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::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, CompiledAccessProfile, CompiledOperation, CompiledResource, + ConsultationPattern, OperationKind, RowAuthoritySource, POINT_BBOX_PREDICATE, +}; +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 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 ResponseFormat { + Json, + JsonLd, + GeoJson(GeoJsonProfile), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum GeoJsonProfile { + Rfc7946, + JsonFg, +} + +impl ResponseFormat { + const fn media_type(self) -> &'static str { + 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(RFC7946_PROFILE_URI), + Self::Json | Self::JsonLd => None, + } + } +} + +#[derive(Clone)] +struct Access { + principal: Option, + authorization: Authorization, + access_profile: CompiledAccessProfile, +} + +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 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, access_profile)| { + capability(&service, resource, operation, access_profile) + })); + } + } + 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(access_profile_identifier) = artifact.access_profile_identifier.as_deref() + else { + return ProblemCode::ResourceNotFound.response(&trace); + }; + let Some(access_profile) = operation + .access_profiles + .iter() + .find(|access_profile| access_profile.id == access_profile_identifier) + else { + return ProblemCode::ResourceNotFound.response(&trace); + }; + let Some(authenticator) = &service.authenticator else { + return ProblemCode::ResourceNotFound.response(&trace); + }; + if authenticator + .authorize(&access_profile.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 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, principal.as_ref(), &trace, OperationClass::List) + .await; + }; + 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(), + principal, + &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 rejects_caller_purpose(&headers) { + return refuse_known( + service, + resource, + operation, + Some(&access), + AuditOutcome::InvalidRequest, + ProblemCode::ConsultationInvalidRequest, + &trace, + ) + .await; + } + let response_format = match negotiate(&headers, resource, &access.access_profile) { + Ok(value) => value, + Err(code) => { + return refuse_known( + service, + resource, + operation, + Some(&access), + AuditOutcome::InvalidRequest, + code, + &trace, + ) + .await + } + }; + let query = match prepare_collection( + service, + resource, + operation, + &access, + response_format, + 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, + Some(&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, + &access.access_profile.id, + 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)), + bbox: query.bbox, + ..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 { + 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.access_profile, + 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); + } + 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 source_shape_failure(&service.audit, &audit, &trace).await, + } + } else { + None + }; + let meta = record_meta( + service, + resource, + operation, + &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) + } + ResponseFormat::Json | ResponseFormat::JsonLd => json!({ + "items": items, + "pageInfo": {"nextCursor": next_cursor}, + "meta": meta, + }), + }; + apply_json_ld( + service, + resource, + &access.access_profile, + query.response_format, + &mut document, + ); + release_document( + service, + &audit, + document, + query.response_format, + cacheable(&access.access_profile, &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 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, 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( + &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 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, 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, + 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 (response_format, fields) = match prepare_single_request( + resource, + operation, + &access.access_profile, + request.headers(), + 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((response_format, 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<(ResponseFormat, 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, fields) = match prepare_single_request( + resource, + operation, + &access.access_profile, + headers, + 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, + 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, + &access.access_profile.id, + 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 record = match record_value( + service, + resource, + &access.access_profile, + &result.rows[0], + &fields, + ) { + Ok(value) => value, + Err(RecordError::InvalidSource | RecordError::InvalidCore) => { + return source_shape_failure(&service.audit, &audit, trace).await; + } + }; + let meta = record_meta( + service, + resource, + operation, + &access.access_profile, + &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, + &access.access_profile, + representation, + &mut document, + ); + release_document( + service, + &audit, + document, + representation, + cacheable(&access.access_profile, &result.source_revision), + headers, + trace, + ) + .await +} + +async fn access_operation( + service: &RelayService, + resource: &CompiledResource, + operation: &CompiledOperation, + query: Option<&str>, + principal: Option, + trace: &TraceContext, +) -> Result> { + let selected = match select_access_profile(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) => { + return Err(refuse_before_access_profile( + service, + resource, + operation, + principal_kind(principal.as_ref()), + AuditOutcome::InvalidRequest, + code, + trace, + ) + .await); + } + }; + let access_profile = selected.access_profile; + let explicit = selected.explicit; + let authorization = match &service.authenticator { + Some(authenticator) => authenticator.authorize(&access_profile.access, principal.as_ref()), + None => match access_profile.access { + CompiledAccess::Public => Ok(Authorization { + row_authority: None, + purpose: None, + }), + CompiledAccess::Protected { .. } => Err(AuthorizationError::AuthenticationRequired), + }, + }; + match authorization { + Ok(authorization) => Ok(Access { + principal, + authorization, + access_profile: access_profile.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, + PrincipalKind::Authenticated, + AuditOutcome::NotFound, + ProblemCode::ResourceNotFound, + trace, + ) + .await); + } + 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, + }, + access_profile: access_profile.clone(), + }; + Err(refuse_known( + service, + resource, + operation, + Some(&denied_access), + outcome, + code, + trace, + ) + .await) + } + } +} + +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, +) -> 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, + Search, +} + +async fn unknown_data_route( + service: &RelayService, + principal: Option<&Principal>, + trace: &TraceContext, + class: OperationClass, +) -> Response { + let protected = service.registry.resources.iter().any(|resource| { + resource.operations.iter().any(|operation| { + class_matches(&operation.kind, class) + && operation.access_profiles.iter().any(|access_profile| { + matches!(access_profile.access, CompiledAccess::Protected { .. }) + }) + }) + }); + if protected && principal.is_none() { + return refuse_unknown( + service, + PrincipalKind::Unknown, + AuditOutcome::MissingCredential, + ProblemCode::MissingCredential, + trace, + ) + .await; + } + refuse_unknown( + service, + if principal.is_some() { + PrincipalKind::Authenticated + } else { + PrincipalKind::Anonymous + }, + AuditOutcome::NotFound, + ProblemCode::ResourceNotFound, + trace, + ) + .await +} + +fn class_matches(kind: &OperationKind, class: OperationClass) -> bool { + matches!( + (kind, class), + (OperationKind::List, OperationClass::List) + | (OperationKind::Read, OperationClass::Read) + | (OperationKind::Lookup { .. }, OperationClass::Lookup) + | (OperationKind::Search { .. }, OperationClass::Search) + ) +} + +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, + operation: &CompiledOperation, + access: Option<&Access>, + outcome: AuditOutcome, + code: ProblemCode, + trace: &TraceContext, +) -> Response { + 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_access_profile( + 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); + } + 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, + Some(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: Option<&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.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.access_profile) + }), + 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.access_profile.processing_handling).into()), + disclosure_handling: access + .map(|access| handling_label(access.access_profile.disclosure_handling).into()), + transform_identifiers: access.map_or_else(Vec::new, |access| { + transform_identifiers(&access.access_profile) + }), + contract_revision: service.registry.contract_revision.clone(), + source_revision: service + .sqlite + .source_revision(&operation.identifier) + .cloned(), + 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 + } +} + +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, + access_profile: None, + disclosure_profile: None, + processing_description_identifiers: Vec::new(), + selected_properties: Vec::new(), + processing_handling: None, + disclosure_handling: None, + transform_identifiers: Vec::new(), + 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}"), + OperationKind::Search { name } => format!("search:{name}"), + }; + resource + .processing_descriptions + .iter() + .filter(|description| description.operation_refs.contains(&reference)) + .map(|description| description.id.clone()) + .collect::>() + .into_iter() + .collect() +} + +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(access_profile: &CompiledAccessProfile) -> Vec { + access_profile + .transform_inventory + .iter() + .filter_map(|entry| { + entry + .split_once('=') + .map(|(_, identifier)| identifier.to_owned()) + }) + .collect::>() + .into_iter() + .collect() +} + +fn row_boundary(access_profile: &CompiledAccessProfile) -> RowBoundaryKind { + match &access_profile.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 PreparedCollection { + page_size: u32, + 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 SelectedAccessProfile<'a> { + access_profile: &'a CompiledAccessProfile, + explicit: bool, +} + +fn select_access_profile<'a>( + operation: &'a CompiledOperation, + query: Option<&str>, +) -> Result, ProblemCode> { + let requested = access_profile_parameter(query)?; + let identifier = requested + .as_deref() + .unwrap_or(&operation.default_access_profile); + if !valid_access_profile_identifier(identifier) { + return Err(ProblemCode::AccessProfileInvalid); + } + let explicit = requested.is_some(); + operation + .access_profiles + .iter() + .find(|access_profile| access_profile.id == identifier) + .map(|access_profile| SelectedAccessProfile { + access_profile, + explicit, + }) + .ok_or(ProblemCode::ResourceNotFound) +} + +/// 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 access-profile value. It therefore +/// preserves exact-profile authorization for an oversized URI without +/// allocating or decoding unrelated attacker-controlled query values. +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 { + 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 != "accessProfile" { + continue; + } + if requested.is_some() + || raw_value.len() > MAXIMUM_ENCODED_VALUE_BYTES + || raw_value.contains('=') + { + return Err(ProblemCode::AccessProfileInvalid); + } + 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::AccessProfileInvalid); + } + url::form_urlencoded::parse(raw.as_bytes()) + .next() + .map(|(value, _)| value.into_owned()) + .ok_or(ProblemCode::AccessProfileInvalid) +} + +fn valid_access_profile_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_collection( + service: &RelayService, + resource: &CompiledResource, + operation: &CompiledOperation, + access: &Access, + negotiated: ResponseFormat, + 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 + .iter() + .any(|(name, _)| name != "cursor" && name != "accessProfile") + { + 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::>(); + 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.access_profile, + &payload.selected_fields, + )?; + let response_format = + 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); + } + let current_source_revision = service + .sqlite + .source_revision(&operation.identifier) + .ok_or(ProblemCode::CursorInvalid)? + .cursor_value(); + let request = cursor_template( + service, + operation, + access, + 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(PreparedCollection { + 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(), + ), + bbox, + response_format, + }); + } + + let mut page_size = pagination.default_page_size; + let mut page_size_seen = false; + let mut fields_text = None; + let mut format_profile_text = None; + let mut bbox_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); + } + } + "formatProfile" => { + if format_profile_text.replace(value).is_some() { + return Err(ProblemCode::UnsupportedFormat); + } + } + "bbox" => { + if operation.query.spatial_bbox.is_none() { + return Err(ProblemCode::UnknownFilter); + } + if bbox_text.replace(value).is_some() { + return Err(ProblemCode::InvalidFilter); + } + } + "accessProfile" => {} + _ if declared.contains(name.as_str()) => { + if raw_filters.insert(name, value).is_some() { + return Err(ProblemCode::InvalidFilter); + } + } + _ => return Err(ProblemCode::UnknownFilter), + } + } + let bbox = bbox_text + .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); + } + 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, + &access.access_profile, + fields_text.as_deref(), + )?; + let response_format = select_format_profile( + resource, + &access.access_profile, + negotiated, + format_profile_text.as_deref(), + )?; + Ok(PreparedCollection { + page_size, + filters, + selected_fields, + after_order: None, + bbox, + response_format, + }) +} + +fn prepare_single_request( + resource: &CompiledResource, + operation: &CompiledOperation, + access_profile: &CompiledAccessProfile, + headers: &HeaderMap, + query: Option<&str>, +) -> Result<(ResponseFormat, Vec), ProblemCode> { + let negotiated = negotiate(headers, resource, access_profile)?; + let parameters = parse_query(query)?; + if parameters + .iter() + .any(|(name, _)| name != "fields" && name != "formatProfile" && name != "accessProfile") + { + return Err(ProblemCode::ConsultationInvalidRequest); + } + let fields = one_parameter(¶meters, "fields")?; + let format_profile = + one_parameter(¶meters, "formatProfile").map_err(|_| ProblemCode::UnsupportedFormat)?; + Ok(( + 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, + access_profile: &CompiledAccessProfile, + text: Option<&str>, +) -> Result, ProblemCode> { + let Some(text) = text else { + return Ok(access_profile.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 = access_profile + .selectable_properties + .iter() + .map(String::as_str) + .collect::>(); + if requested.iter().any(|field| { + !allowed.contains(field) + || !(resource + .properties + .iter() + .any(|property| property.name == **field) + || resource + .primary_geometry + .as_ref() + .is_some_and(|geometry| geometry.name == **field)) + }) { + return Err(ProblemCode::FieldsInvalid); + } + Ok(access_profile + .selectable_properties + .iter() + .filter(|field| requested.contains(&field.as_str())) + .cloned() + .collect()) +} + +fn validate_selected_inventory( + resource: &CompiledResource, + operation: &CompiledOperation, + 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, access_profile, Some(&text)) + .map_err(|_| ProblemCode::CursorInvalid)?; + if canonical != fields { + return Err(ProblemCode::CursorInvalid); + } + Ok(()) +} + +fn validate_filter_inventory( + operation: &CompiledOperation, + 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 + .iter() + .map(|filter| filter.parameter.as_str()) + .collect::>(); + if filters.keys().any(|name| !declared.contains(name.as_str())) + || (filters.is_empty() && !bbox_present && !operation.query.allow_unfiltered) + { + return Err(ProblemCode::CursorInvalid); + } + Ok(()) +} + +fn select_format_profile( + resource: &CompiledResource, + access_profile: &CompiledAccessProfile, + negotiated: ResponseFormat, + requested: Option<&str>, +) -> Result { + match negotiated { + ResponseFormat::Json | ResponseFormat::JsonLd => { + if requested.is_some() { + return Err(ProblemCode::UnsupportedFormat); + } + Ok(negotiated) + } + ResponseFormat::GeoJson(_) => { + let profile = match requested.unwrap_or("rfc7946") { + "rfc7946" => GeoJsonProfile::Rfc7946, + "jsonfg" => GeoJsonProfile::JsonFg, + _ => return Err(ProblemCode::UnsupportedFormat), + }; + if supports_geojson(resource, access_profile) { + Ok(ResponseFormat::GeoJson(profile)) + } else { + Err(ProblemCode::UnsupportedFormat) + } + } + } +} + +fn response_format_from_cursor( + resource: &CompiledResource, + access_profile: &CompiledAccessProfile, + payload: &CursorPayload, +) -> Result { + match ( + payload.response_format.as_str(), + payload.format_profile.as_deref(), + ) { + ("json", None) => Ok(ResponseFormat::Json), + ("json-ld", None) => Ok(ResponseFormat::JsonLd), + ("geojson", Some(profile)) => select_format_profile( + resource, + access_profile, + ResponseFormat::GeoJson(GeoJsonProfile::Rfc7946), + Some(profile), + ), + _ => Err(ProblemCode::CursorInvalid), + } +} + +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()); + }; + 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())), + 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())), + } +} + +#[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())) + }), + 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, + access_profile: &CompiledAccessProfile, + row: &ResultRow, + selected: &[String], +) -> Result { + let record_identifier = required_string(row, &resource.record_context.record_identifier_column) + .ok_or(RecordError::InvalidCore)?; + if !valid_record_identifier(record_identifier) { + 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( + service, + Some(&resource.record_context.lifecycle_state_codelist), + lifecycle, + ) + { + return Err(RecordError::InvalidCore); + } + // Validate the complete selected access profile before requester field + // minimization. Narrowing disclosure never lowers its processing floor. + let properties = access_profile + .selectable_properties + .iter() + .filter_map(|name| { + resource + .properties + .iter() + .find(|property| property.name == *name) + }) + .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 Err(RecordError::InvalidSource); + } + continue; + } + if !valid_property_value( + service, + &value, + property.data_type, + property.codelist.as_deref(), + ) { + return Err(RecordError::InvalidSource); + } + transformed.insert(property.name.as_str(), value); + } + let selected_geometry = resource.primary_geometry.as_ref().filter(|geometry| { + access_profile + .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) { + continue; + } + if let Some(value) = transformed.remove(property.name.as_str()) { + domain.insert( + property.name.clone(), + sql_to_json(value).ok_or(RecordError::InvalidSource)?, + ); + } + } + 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, + "revisionIdentifier": revision, + "lifecycleState": lifecycle, + "schemaReference": access_profile.schema_reference, + "semanticModelReference": access_profile.semantic_model_reference, + "authorityIdentifier": service.registry.authority_identifier, + "recordedAt": recorded_at, + "domainData": domain, + })) +} + +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, + 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::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 + } + _ => 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, + access_profile: &CompiledAccessProfile, + selected: &[String], + source_revision: &SourceRevision, +) -> Value { + let pattern = operation_pattern(operation.pattern); + json!({ + "operationIdentifier": operation.identifier, + "accessProfile": access_profile.id, + "family": "consultation", + "pattern": pattern, + "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": access_profile.context_reference, + "schema": access_profile.schema_reference, + "semanticModel": access_profile.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 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_CONFORMANCE,]), + ); + object.insert("featureType".into(), Value::String(resource.id.clone())); +} + +fn apply_json_ld( + service: &RelayService, + resource: &CompiledResource, + selected: &CompiledAccessProfile, + representation: ResponseFormat, + document: &mut Value, +) { + if representation != ResponseFormat::JsonLd { + return; + } + 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") { + 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), + )), + ); + object.insert( + "@type".into(), + Value::String(resource.semantic_class.clone()), + ); + } +} + +async fn release_document( + service: &RelayService, + audit: &AuditContext, + document: Value, + representation: ResponseFormat, + 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); + } + let mut response = not_modified(etag.as_deref().unwrap_or_default(), trace); + apply_profile_link(&mut response, representation); + return response; + } + if service + .audit + .terminal(audit, AuditOutcome::Released, Some(&bytes)) + .await + .is_err() + { + return ProblemCode::AuditUnavailable.response(trace); + } + 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 { + 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::UnknownOperation | SqliteRuntimeError::InvalidPlan => { + (AuditOutcome::InternalFailed, ProblemCode::Internal) + } + SqliteRuntimeError::MissingSource + | SqliteRuntimeError::SchemaMismatch + | 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 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, + 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(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, + access_profile: &CompiledAccessProfile, +) -> Result { + 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 !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 == preferred { + Ok(ResponseFormat::GeoJson(GeoJsonProfile::Rfc7946)) + } 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") +} + +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: &PreparedCollection, + 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, + 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() + .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 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, + access: &Access, + context: CursorQueryContext<'_>, +) -> Result { + let key = service + .cursor_key + .as_ref() + .ok_or(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.access_profile.transform_inventory) + .map_err(|_| ProblemCode::CursorInvalid)?; + let authorization_material = access + .principal + .as_ref() + .map(|principal| { + principal.authorization_material(&access.access_profile.access, &access.authorization) + }) + .unwrap_or_else(|| b"anonymous".to_vec()); + Ok(CursorPayload::new( + u64::MAX, + service.registry.contract_revision.clone(), + context.source_revision.to_owned(), + operation.identifier.clone(), + CursorBindings { + 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)?, + 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(), + }, + ) + .with_response_context( + context.bbox.map(canonical_bbox), + context.response_format.cursor_kind().to_owned(), + context.response_format.cursor_profile().map(str::to_owned), + )) +} + +fn metadata_cursor_template( + service: &RelayService, + visible: &[(&CompiledResource, Vec>)], +) -> 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, representation)| { + (operation.identifier.as_str(), representation.id.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 { + access_profile: "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)?, + 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>)], + 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() + .flat_map(|operation| { + operation + .access_profiles + .iter() + .filter(|access_profile| { + matches!(access_profile.access, CompiledAccess::Public) + }) + .map(move |access_profile| (operation, access_profile)) + }) + .collect()), + Visibility::OperationBound => { + let principal = principal.ok_or(ProblemCode::MissingCredential)?; + let authenticator = service + .authenticator + .as_ref() + .ok_or(ProblemCode::ResourceNotFound)?; + Ok(resource + .operations + .iter() + .flat_map(|operation| { + operation + .access_profiles + .iter() + .filter_map(move |access_profile| { + authenticator + .authorize(&access_profile.access, Some(principal)) + .is_ok() + .then_some((operation, access_profile)) + }) + }) + .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 +} + +type VisibleAccessProfile<'a> = (&'a CompiledOperation, &'a CompiledAccessProfile); + +fn resource_document( + service: &RelayService, + resource: &CompiledResource, + operations: &[VisibleAccessProfile<'_>], +) -> Value { + let enumeration = if operations + .iter() + .any(|(operation, _)| matches!(operation.kind, OperationKind::List)) + { + if operations.iter().any(|(operation, access_profile)| { + matches!(operation.kind, OperationKind::List) + && matches!(access_profile.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, access_profile)| capability(service, resource, operation, access_profile)).collect::>(), + "links": { + "self": absolute(&service.registry.base_uri, &format!("/v2/resources/{}", resource.id)), + } + }) +} + +fn capability( + service: &RelayService, + resource: &CompiledResource, + operation: &CompiledOperation, + access_profile: &CompiledAccessProfile, +) -> Value { + let mut document = json!({ + "family": "consultation", + "pattern": operation_pattern(operation.pattern), + "resourceIdentifier": resource.id, + "operationIdentifier": operation.identifier, + "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, + "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!( + "{}--access-profile-{}", + operation_artifact_stem(&resource.id, &operation.kind), + access_profile.id + ); + let object = document + .as_object_mut() + .expect("capability document is an object"); + object.insert( + "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( + "spatialQuery".into(), + json!({ + "bbox": { + "crs": CRS84_URI, + "predicate": POINT_BBOX_PREDICATE, + "maximumLongitudeSpanDegrees": spatial.maximum_longitude_span_degrees, + "maximumLatitudeSpanDegrees": spatial.maximum_latitude_span_degrees, + } + }), + ); + } + if service.registry.metadata_visibility.classifications != Visibility::OperatorOnly { + object.insert( + "classificationReference".into(), + Value::String(sibling_artifact_reference( + &access_profile.schema_reference, + &format!("{stem}-classifications"), + )), + ); + } + if service.registry.metadata_visibility.processing != Visibility::OperatorOnly { + object.insert( + "processingReference".into(), + Value::String(sibling_artifact_reference( + &access_profile.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}"), + OperationKind::Search { name } => format!("{resource}--search-{name}"), + } +} + +fn operation_pattern(pattern: ConsultationPattern) -> &'static str { + match pattern { + ConsultationPattern::List => "list", + ConsultationPattern::Retrieve => "retrieve", + ConsultationPattern::Search => "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) + } + OperationKind::Search { name } => { + format!("/v2/resources/{}/searches/{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::*; + 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() { + 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"}); + 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()); + } + + #[test] + fn access_profile_selection_scans_only_bounded_components() { + let padding = "x".repeat(20_000); + let query = format!("padding={padding}&accessProfile=caseworker"); + assert_eq!( + access_profile_parameter(Some(&query)).expect("selector extracts"), + Some("caseworker".into()) + ); + assert_eq!( + access_profile_parameter(Some("%61ccessProfile=limited")) + .expect("encoded selector extracts"), + Some("limited".into()) + ); + assert_eq!( + access_profile_parameter(Some("%=ignored&accessProfile=limited")) + .expect("malformed unrelated name is deferred"), + Some("limited".into()) + ); + assert_eq!( + 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!( + access_profile_parameter(Some("representation=legacy")) + .expect("legacy selector is not an alias"), + 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 new file mode 100644 index 000000000..236ab314c --- /dev/null +++ b/crates/registry-relay-v2/src/artifacts.rs @@ -0,0 +1,1841 @@ +// 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::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, POINT_BBOX_PREDICATE, +}; +use crate::semantics::{ + access_profile_schema, access_profile_shacl, full_record_schema, full_record_shacl, + json_ld_context, local_vocabulary, +}; + +#[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, + /// Present with `operation_identifier` when an operation-bound artifact + /// belongs to one exact finite access profile. + pub access_profile_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 access_profile_identifier: String, + pub vocabulary_path: String, + pub context_path: String, + pub access_profile_schema_path: String, + pub access_profile_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()) + .chain( + resource + .primary_geometry + .iter() + .map(|geometry| geometry.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!({ + "resourceIdentifier": resource.id, + "properties": resource.properties.iter().map(|property| json!({ + "property": property.name, + "classification": property.classification, + })).chain(resource.primary_geometry.iter().map(|geometry| json!({ + "property": geometry.name, + "classification": geometry.classification, + "geometryType": "Point", + "crs": geometry.crs, + }))).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 { + for access_profile in &operation.access_profiles { + let disclosure = resource + .disclosure_profiles + .iter() + .find(|profile| profile.id == access_profile.disclosure_profile) + .ok_or(ArtifactError::MissingDisclosure)?; + let suffix = + access_profile_artifact_stem(&resource.id, &operation.kind, &access_profile.id); + if matches!(&access_profile.access, CompiledAccess::Protected { .. }) { + push_access_profile_json( + &mut artifacts, + &format!("{suffix}-capability"), + &format!("artifacts/{suffix}.capability.json"), + "application/json", + Visibility::OperationBound, + &operation.identifier, + &access_profile.id, + &capability_inventory( + registry, + CapabilityProjection::AccessProfile( + &operation.identifier, + &access_profile.id, + ), + ), + )?; + } + let semantic_visibility = projection_visibility( + registry.metadata_visibility.semantics, + &access_profile.access, + ); + 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_access_profile_json( + &mut artifacts, + &format!("{suffix}-vocabulary"), + &vocabulary_path, + "application/ld+json", + semantic_visibility, + &operation.identifier, + &access_profile.id, + &local_vocabulary(registry, resource, &disclosure.properties), + )?; + push_access_profile_json( + &mut artifacts, + &format!("{suffix}-context"), + &context_path, + "application/ld+json", + semantic_visibility, + &operation.identifier, + &access_profile.id, + &json_ld_context(registry, resource, &disclosure.properties), + )?; + push_access_profile_json( + &mut artifacts, + &format!("{suffix}-schema"), + &schema_path, + "application/schema+json", + semantic_visibility, + &operation.identifier, + &access_profile.id, + &access_profile_schema( + registry, + resource, + &disclosure.properties, + &access_profile.schema_reference, + &access_profile.semantic_model_reference, + ), + )?; + if supports_geojson(resource, access_profile) { + push_access_profile_json( + &mut artifacts, + &format!("{suffix}-geojson-schema"), + &format!("artifacts/{suffix}.geojson.schema.json"), + "application/schema+json", + semantic_visibility, + &operation.identifier, + &access_profile.id, + &geojson_response_schema( + registry, + operation, + access_profile, + resource, + true, + ), + )?; + } + push_access_profile_text( + &mut artifacts, + &format!("{suffix}-shacl"), + &shacl_path, + "text/turtle", + semantic_visibility, + &operation.identifier, + &access_profile.id, + access_profile_shacl(registry, resource, &disclosure.properties).into_bytes(), + ); + let classification_visibility = projection_visibility( + registry.metadata_visibility.classifications, + &access_profile.access, + ); + push_access_profile_json( + &mut artifacts, + &format!("{suffix}-classifications"), + &classification_path, + "application/json", + classification_visibility, + &operation.identifier, + &access_profile.id, + &json!({ + "resourceIdentifier": resource.id, + "operationIdentifier": operation.identifier, + "accessProfileIdentifier": access_profile.id, + "disclosureProfile": access_profile.disclosure_profile, + "processingHandling": access_profile.processing_handling, + "disclosureHandling": access_profile.disclosure_handling, + "transformIdentifiers": access_profile.transform_inventory, + "properties": resource.properties.iter() + .filter(|property| disclosure.properties.contains(&property.name)) + .map(|property| json!({ + "property": property.name, + "classification": property.classification, + "transform": property.transform, + })).chain(resource.primary_geometry.iter() + .filter(|geometry| disclosure.properties.contains(&geometry.name)) + .map(|geometry| json!({ + "property": geometry.name, + "classification": geometry.classification, + "geometryType": "Point", + "crs": geometry.crs, + }))) + .collect::>(), + }), + )?; + let processing_visibility = projection_visibility( + registry.metadata_visibility.processing, + &access_profile.access, + ); + let operation_ref = operation_contract_reference(&operation.kind); + push_access_profile_json( + &mut artifacts, + &format!("{suffix}-processing"), + &processing_path, + "application/json", + processing_visibility, + &operation.identifier, + &access_profile.id, + &json!({ + "resourceIdentifier": resource.id, + "operationIdentifier": operation.identifier, + "accessProfileIdentifier": access_profile.id, + "processingHandling": access_profile.processing_handling, + "disclosureHandling": access_profile.disclosure_handling, + "transformIdentifiers": access_profile.transform_inventory, + "descriptions": resource.processing_descriptions.iter() + .filter(|description| description.operation_refs.contains(&operation_ref)) + .collect::>(), + }), + )?; + bindings.push(OperationArtifactBindings { + operation_identifier: operation.identifier.clone(), + access_profile_identifier: access_profile.id.clone(), + vocabulary_path, + context_path, + access_profile_schema_path: schema_path, + access_profile_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) + .then( + left.access_profile_identifier + .cmp(&right.access_profile_identifier), + ) + }); + Ok(ArtifactSet { + contract_revision: registry.contract_revision.clone(), + artifacts, + operation_bindings: bindings, + }) +} + +fn projection_visibility(configured: Visibility, access: &CompiledAccess) -> Visibility { + match configured { + Visibility::OperatorOnly => Visibility::OperatorOnly, + Visibility::Public | 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}"), + OperationKind::Search { name } => format!("search:{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}"), + OperationKind::Search { name } => format!("{resource}--search-{name}"), + } +} + +fn access_profile_artifact_stem( + resource: &str, + kind: &OperationKind, + access_profile: &str, +) -> String { + format!( + "{}--access-profile-{access_profile}", + operation_artifact_stem(resource, kind) + ) +} + +#[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_access_profile_json( + artifacts: &mut Vec, + id: &str, + path: &str, + media_type: &str, + visibility: Visibility, + operation_identifier: &str, + access_profile_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("an access-profile artifact was appended") + .access_profile_identifier = bound.then(|| access_profile_identifier.to_owned()); + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +fn push_access_profile_text( + artifacts: &mut Vec, + id: &str, + path: &str, + media_type: &str, + visibility: Visibility, + operation_identifier: &str, + access_profile_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("an access-profile artifact was appended") + .access_profile_identifier = bound.then(|| access_profile_identifier.to_owned()); +} + +#[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, + access_profile_identifier: None, + 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 { + let visible_access_profiles = operation + .access_profiles + .iter() + .filter(|access_profile| { + !public_only || matches!(&access_profile.access, CompiledAccess::Public) + }) + .collect::>(); + if visible_access_profiles.is_empty() { + continue; + } + 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), + ), + OperationKind::Lookup { name } => ( + "post", + format!("/v2/resources/{}/lookups/{name}", resource.id), + ), + OperationKind::Search { name } => ( + "get", + format!("/v2/resources/{}/searches/{name}", resource.id), + ), + }; + let has_public = visible_access_profiles + .iter() + .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 access profile exists"), + }; + let visible_identifiers = visible_access_profiles + .iter() + .map(|access_profile| access_profile.id.clone()) + .collect::>(); + let visible_default = visible_identifiers + .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 { + access_profile_schema + .as_object_mut() + .expect("access-profile schema object") + .insert("default".into(), json!(default)); + } + let mut parameters = vec![ + json!({ + "name": "accessProfile", + "in": "query", + "required": false, + "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 access profile" + }), + ]; + let has_geojson = visible_access_profiles + .iter() + .any(|access_profile| supports_geojson(resource, access_profile)); + if has_geojson { + parameters.push(json!({ + "name": "formatProfile", + "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 | OperationKind::Search { .. } => { + 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, + })); + } + 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": "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, + })); + } + } + OperationKind::Read => parameters.push(json!({ + "name": "recordIdentifier", "in": "path", "required": true, + "schema": {"type": "string", "minLength": 1} + })), + OperationKind::Lookup { .. } => {} + } + let mut success_response = json!({ + "description": "A validated minimum-disclosure Registry response", + "content": operation_response_content( + registry, + operation, + resource, + &visible_access_profiles, + ) + }); + 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": consultation_pattern(operation.pattern), + "x-registry-access-profiles": visible_access_profiles.iter().map(|access_profile| json!({ + "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, + "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, + "responses": { + "200": success_response, + "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 { + CompiledAccess::Public => None, + CompiledAccess::Protected { scope, .. } => Some(json!({ + "accessProfileIdentifier": access_profile.id, + "scope": scope, + })), + }) + .collect::>(); + if !required_scopes.is_empty() { + operation_value + .as_object_mut() + .expect("operation object") + .insert("x-registry-required-scopes".into(), json!(required_scopes)); + } + 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, + access_profiles: &[&crate::model::CompiledAccessProfile], +) -> Value { + let meta = json!({"type": "object"}); + let record = if access_profiles.len() == 1 { + json!({"$ref": access_profiles[0].schema_reference}) + } else { + json!({ + "anyOf": access_profiles.iter().map(|access_profile| { + json!({"$ref": access_profile.schema_reference}) + }).collect::>() + }) + }; + match &operation.kind { + OperationKind::List | OperationKind::Search { .. } => json!({ + "type": "object", "additionalProperties": false, + "required": ["items", "pageInfo", "meta"], + "properties": { + "items": {"type": "array", "items": record}, + "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": record, + "meta": meta + } + }), + } +} + +fn operation_response_content( + registry: &CompiledRegistry, + operation: &CompiledOperation, + resource: &CompiledResource, + 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": json_ld})), + ]); + let spatial = access_profiles + .iter() + .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() { + let schema = if spatial.len() == 1 { + spatial.into_iter().next().expect("one spatial schema") + } else { + 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, + access_profile: &crate::model::CompiledAccessProfile, + resource: &CompiledResource, + include_identity: bool, +) -> Value { + let mut schema = match &operation.kind { + OperationKind::List | OperationKind::Search { .. } => json!({ + "type": "object", + "additionalProperties": false, + "required": ["type", "features", "pageInfo", "meta"], + "properties": { + "type": {"type": "string", "enum": ["FeatureCollection"]}, + "features": { + "type": "array", + "items": geojson_feature_schema(registry, access_profile, 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, access_profile, 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!(access_profile + .schema_reference + .strip_suffix("-schema") + .map(|base| format!("{base}-geojson-schema")) + .unwrap_or_else(|| format!("{}-geojson", access_profile.schema_reference))), + ); + } + schema +} + +fn geojson_feature_schema( + registry: &CompiledRegistry, + access_profile: &crate::model::CompiledAccessProfile, + 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, access_profile, 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, + access_profile: &crate::model::CompiledAccessProfile, + resource: &CompiledResource, +) -> Value { + let geometry_name = resource + .primary_geometry + .as_ref() + .map(|geometry| geometry.name.as_str()); + let selected = access_profile + .selectable_properties + .iter() + .filter(|property| Some(property.as_str()) != geometry_name) + .cloned() + .collect::>(); + let mut schema = access_profile_schema( + registry, + resource, + &selected, + &access_profile.schema_reference, + &access_profile.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 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"}), + 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" + }), + } +} + +#[derive(Clone, Copy)] +enum CapabilityProjection<'a> { + Public, + Full, + AccessProfile(&'a str, &'a str), +} + +fn capability_inventory( + registry: &CompiledRegistry, + projection: CapabilityProjection<'_>, +) -> Value { + let capabilities = registry + .resources + .iter() + .flat_map(|resource| { + resource.operations.iter().flat_map(move |operation| { + operation.access_profiles.iter().filter_map(move |access_profile| { + let include = match projection { + CapabilityProjection::Public => { + matches!(&access_profile.access, CompiledAccess::Public) + } + CapabilityProjection::Full => true, + CapabilityProjection::AccessProfile( + operation_identifier, + access_profile_identifier, + ) => { + operation.identifier == operation_identifier + && access_profile.id == access_profile_identifier + } + }; + if !include { + return None; + } + let pattern = match &operation.kind { + OperationKind::List => "list", + OperationKind::Read => "retrieve", + OperationKind::Lookup { .. } => "search", + OperationKind::Search { .. } => "search", + }; + Some(json!({ + "resourceIdentifier": resource.id, + "operationIdentifier": operation.identifier, + "accessProfileIdentifier": access_profile.id, + "isDefault": operation.default_access_profile == access_profile.id, + "family": "consultation", + "pattern": pattern, + "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, + "predicate": POINT_BBOX_PREDICATE, + "maximumLongitudeSpanDegrees": spatial.maximum_longitude_span_degrees, + "maximumLatitudeSpanDegrees": spatial.maximum_latitude_span_degrees, + } + })), + })) + }) + }) + }) + .collect::>(); + json!({ + "registryIdentifier": registry.registry_identifier, + "authorityIdentifier": registry.authority_identifier, + "contractRevision": registry.contract_revision, + "apiBinding": {"name": crate::API_BINDING_NAME, "version": crate::API_BINDING_VERSION}, + "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", + "transformIdentifiers", "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"]}, + "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}, + "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", + "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::format_capabilities::JSON_FG_PROFILE_URI; + use crate::model::{CompileProfile, CompiledRegistry}; + + #[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--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}"); + } + 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()); + 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] + 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_profiles[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--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 + .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") + ); + assert_eq!( + artifact.access_profile_identifier.as_deref(), + Some("public") + ); + } + + 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--access-profile-public-vocabulary" + }) + .expect("semantic projection") + .visibility, + Visibility::OperationBound + ); + for id in [ + "record--read--access-profile-public-classifications", + "record--read--access-profile-public-processing", + ] { + assert_eq!( + generated + .artifacts + .iter() + .find(|artifact| artifact.id == id) + .unwrap_or_else(|| panic!("missing {id}")) + .visibility, + Visibility::OperatorOnly + ); + } + } + + #[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); + 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--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"); + 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 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!({ + "registryIdentifier": registry.registry_identifier, + "recordIdentifier": "record-1", + "revisionIdentifier": "revision-1", + "lifecycleState": lifecycle, + "schemaReference": access_profile.schema_reference, + "semanticModelReference": access_profile.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/searches/within-bbox"]["get"]; + assert_eq!(operation["x-registry-pattern"], "search"); + assert!(operation["responses"]["200"]["content"] + .get("application/geo+json") + .is_some()); + 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"], 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!(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(POINT_BBOX_PREDICATE)); + 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(POINT_BBOX_PREDICATE)); + assert!(!public_capabilities.contains("application/geo+json")); + assert!(!public_capabilities.contains(POINT_BBOX_PREDICATE)); + assert!(!public_openapi.contains("/searches/within-bbox")); + assert!(!public_capabilities.contains("record.search.within-bbox")); + + let operation_capability = generated + .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.search.within-bbox") + ); + 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].access_profiles[0].access = access; + registry + } +} diff --git a/crates/registry-relay-v2/src/audit.rs b/crates/registry-relay-v2/src/audit.rs new file mode 100644 index 000000000..fd0768a73 --- /dev/null +++ b/crates/registry-relay-v2/src/audit.rs @@ -0,0 +1,285 @@ +// 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 access_profile: Option, + pub disclosure_profile: Option, + pub processing_description_identifiers: Vec, + pub selected_properties: Vec, + pub processing_handling: Option, + pub disclosure_handling: Option, + pub transform_identifiers: Vec, + 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")] + access_profile: 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")] + 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, + 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, + 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(), + 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, + 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, + }), + } +} + +#[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/auth.rs b/crates/registry-relay-v2/src/auth.rs new file mode 100644 index 000000000..f13c3fe0f --- /dev/null +++ b/crates/registry-relay-v2/src/auth.rs @@ -0,0 +1,612 @@ +// 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_authcommon::parse_bearer_token; +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 = parse_bearer_token(value).map_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 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!({ + "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..486c28eb4 --- /dev/null +++ b/crates/registry-relay-v2/src/compiler.rs @@ -0,0 +1,4954 @@ +// 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::{ + AccessProfileDefinition, AccessRule, AuthorityRowBinding, ClassificationPartial, DataType, + DateInputType, DatePrecision, Handling, IdentificationMethod, RegistryContract, ReviewStatus, + SearchQueryDefinition, SourceProfile, TransformDefinition, +}; +use crate::model::{ + CapabilityFamily, ColumnAccount, ColumnUse, CompileProfile, CompileReport, CompiledAccess, + 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; 6] = [ + "pageSize", + "cursor", + "fields", + "accessProfile", + "formatProfile", + "bbox", +]; +const MAXIMUM_RESOURCES: usize = 128; +const MAXIMUM_PROPERTIES_PER_RESOURCE: usize = 128; +const MAXIMUM_DISCLOSURE_PROFILES_PER_RESOURCE: usize = 64; +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; +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>; + +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], + 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(); + let access_profile_executors = resources + .iter() + .flat_map(|resource| &resource.operations) + .map(|operation| operation.access_profiles.len()) + .sum::(); + if access_profile_executors > MAXIMUM_ACCESS_PROFILE_EXECUTORS_PER_REGISTRY { + compiler.error( + "access_profile.registry_bound_exceeded", + "resources", + "the compiled access profile count exceeds the Registry runtime ceiling", + ); + } + 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(), + classification_review: None, + 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, classification_review, report) = + validate_governed_files(contract, files, profile, ®istry); + 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.classification_review = classification_review; + registry.governed_files = file_digests + .into_iter() + .map(|(path, sha256)| CompiledGovernedFile { + roles: governed_file_roles(contract, registry.classification_review.as_ref(), &path), + path, + sha256, + }) + .collect(); + Ok(registry) +} + +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()); + } + 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)); + } + } + 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 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!( + "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() + usize::from(resource.primary_geometry.is_some()) + > MAXIMUM_PROPERTIES_PER_RESOURCE + { + self.error( + "property.bound_exceeded", + &format!("{root}.properties"), + "the governed scalar and geometry 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, Vec<(&str, EffectiveClassification, bool)>> = + 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 !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", + ); + } + } + 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) + }) { + 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"), + "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 + .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, + semantic_iri, + classification, + }); + } + + 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() { + 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 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 or primary geometry", + ), + } + } + 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, + primary_geometry.as_ref(), + &disclosures, + observed_view, + 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, + primary_geometry.as_ref(), + &disclosures, + observed_columns.as_ref(), + &root, + "read", + OperationKind::Read, + &read.default_access_profile, + &read.access_profiles, + ) { + 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, + primary_geometry.as_ref(), + &disclosures, + observed_columns.as_ref(), + &location, + "lookup", + OperationKind::Lookup { + name: lookup.id.clone(), + }, + &lookup.default_access_profile, + &lookup.access_profiles, + ) { + 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 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", + &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, + primary_geometry.as_ref(), + &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, + primary_geometry, + 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], + primary_geometry: Option<&CompiledPrimaryGeometry>, + disclosures: &[CompiledDisclosureProfile], + observed_columns: Option<&BTreeSet<&str>>, + root: &str, + operation_location: &str, + kind: OperationKind, + default_access_profile: &str, + access_profile_definitions: &crate::contract::OrderedMap, + ) -> Option { + let location = if matches!(operation_location, "lookup" | "search") { + root.to_owned() + } else { + format!("{root}.operations.{operation_location}") + }; + if access_profile_definitions.is_empty() { + self.error( + "access_profile.none", + &format!("{location}.accessProfiles"), + "an operation must declare at least one finite access profile", + ); + return None; + } + if access_profile_definitions.len() > MAXIMUM_ACCESS_PROFILES_PER_OPERATION { + self.error( + "access_profile.bound_exceeded", + &format!("{location}.accessProfiles"), + "the access profile count exceeds the per-operation product ceiling", + ); + } + if !valid_kebab_identifier(default_access_profile) + || access_profile_definitions + .get(default_access_profile) + .is_none() + { + self.error( + "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 { .. } | OperationKind::Search { .. } => { + ConsultationPattern::Search + } + }; + let artifact_stem = operation_artifact_stem(&resource.id, &kind); + 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( + "access_profile.id_invalid", + &access_profile_location, + "access profile identifiers must be URL-safe kebab case", + ); + } + let Some(disclosure) = disclosures + .iter() + .find(|item| item.id == definition.disclosure_profile) + else { + self.error( + "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, + &access_profile_location, + ) else { + continue; + }; + validate_disclosure_access( + &mut self.report, + disclosure, + &access, + matches!(&kind, OperationKind::List | OperationKind::Search { .. }), + &access_profile_location, + ); + 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(), + projected_columns: projected_columns( + resource, + properties, + primary_geometry, + &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!("{access_profile_artifact_stem}-schema"), + ), + semantic_model_reference: artifact_url( + &self.contract.registry.base_uri, + &format!("{access_profile_artifact_stem}-vocabulary"), + ), + context_reference: artifact_url( + &self.contract.registry.base_uri, + &format!("{access_profile_artifact_stem}-context"), + ), + }); + } + if access_profiles + .iter() + .any(|access_profile| matches!(access_profile.access, CompiledAccess::Public)) + && access_profiles + .iter() + .find(|access_profile| access_profile.id == default_access_profile) + .is_some_and(|access_profile| { + !matches!(access_profile.access, CompiledAccess::Public) + }) + { + self.error( + "access_profile.public_default_required", + &format!("{location}.defaultAccessProfile"), + "an operation with a public access profile must use a public default", + ); + } + Some(CompiledOperation { + identifier, + family: CapabilityFamily::Consultation, + pattern, + kind, + default_access_profile: default_access_profile.to_owned(), + access_profiles, + query: QueryPlan { + 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, + pagination: None, + maximum_request_body_bytes: None, + }, + }) + } + + #[allow(clippy::too_many_arguments)] + fn compile_list( + &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>>, + root: &str, + list: &crate::contract::ListOperation, + ) -> Option { + let mut operation = self.compile_simple_operation( + resource, + properties, + primary_geometry, + disclosures, + observed_columns, + root, + "list", + OperationKind::List, + &list.default_access_profile, + &list.access_profiles, + )?; + 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.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", + &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(); + let mut order_columns = HashSet::new(); + for (index, property_name) in list.order_by.iter().enumerate() { + 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) => { + 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", + &format!("{location}.orderBy"), + "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", + &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"), + "a fixed order key must name a published property", + ), + } + } + let record_identifier = &resource.record_context.record_identifier.source_column; + // 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 { + default_page_size: list.pagination.default_page_size, + maximum_page_size: list.pagination.maximum_page_size, + }); + 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>, + 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 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( + "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, + 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], + primary_geometry: Option<&CompiledPrimaryGeometry>, + operations: &[CompiledOperation], + property_columns: &HashMap<&str, Vec<(&str, EffectiveClassification, bool)>>, + 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())); + } + 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); + } + for selector in &operation.query.selectors { + uses.entry(&selector.source_column) + .or_default() + .insert(ColumnUse::Selector(selector.name.clone())); + } + for access_profile in &operation.access_profiles { + if let CompiledAccess::Protected { + row_binding: Some(row_binding), + .. + } = &access_profile.access + { + uses.entry(&row_binding.source_column).or_default().insert( + ColumnUse::RowBinding(format!( + "{}:{}", + operation.identifier, access_profile.id + )), + ); + } + } + } + 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) { + 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_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) if !requires_explicit_review => effective_classification( + self.contract, + &classification_to_partial(property), + source_override, + ), + None => effective_classification( + self.contract, + &resource.classification_defaults, + source_override, + ), + Some(_) => 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(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 classification cannot weaken a direct property handling floor", + ); + } + } + 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 location = match &operation.kind { + OperationKind::List => format!("{root}.operations.list"), + OperationKind::Read => format!("{root}.operations.read"), + OperationKind::Lookup { name } => { + format!("{root}.operations.lookups.{name}") + } + OperationKind::Search { name } => { + format!("{root}.operations.searches.{name}") + } + }; + for access_profile in &mut operation.access_profiles { + let mut referenced = BTreeSet::new(); + referenced.extend(access_profile.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)); + if let Some(spatial) = &operation.query.spatial_bbox { + referenced.insert(&spatial.longitude_column); + referenced.insert(&spatial.latitude_column); + } + referenced.extend( + operation + .query + .selectors + .iter() + .map(|selector| selector.source_column.as_str()), + ); + if let CompiledAccess::Protected { + row_binding: Some(binding), + .. + } = &access_profile.access + { + referenced.insert(&binding.source_column); + } + 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 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", + &access_profile_location, + "anonymous access profiles may process only public-handling reviewed columns", + ); + } + if access_profile.processing_handling == Handling::Restricted + && matches!( + &operation.kind, + OperationKind::List | OperationKind::Search { .. } + ) + { + self.error( + "operation.restricted_list_forbidden", + &access_profile_location, + "restricted reviewed data cannot be processed by a collection operation", + ); + } + } + } + } + + 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| { + operation + .access_profiles + .iter() + .any(|access_profile| matches!(access_profile.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", + ); + } + } + // Classification and processing artifacts are projected per finite + // access profile. A protected access profile is operation-bound even + // when a public sibling permits public metadata for its own profile. + } + + 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}"), + OperationKind::Search { name } => reference == &format!("search:{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)))) +} + +/// 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 access profile 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_access_profile: &'a str, + access_profiles: Vec>, + } + + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct AccessProfileInventory<'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_access_profile: &operation.default_access_profile, + access_profiles: operation + .access_profiles + .iter() + .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(), + }) + .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 { + 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 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 { + 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(), None, report); + } + let mut codelist_paths = BTreeSet::new(); + let mut sidecar_paths = BTreeSet::new(); + 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 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()); + } + } + 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()) + .copied() + .collect::>(); + for path in files.keys() { + if !expected.contains(path.as_str()) { + report.diagnostics.push(Diagnostic { + 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(), + }); + } + } + let mut file_digests = BTreeMap::new(); + for path in &expected { + let Some(content) = files.get(*path) else { + report.diagnostics.push(Diagnostic { + 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(), + }); + continue; + }; + file_digests.insert((*path).into(), digest(content)); + if *path != contract.classifications.provenance_ref + && !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, classification_review, 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 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) { + 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 && is_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], + primary_geometry: Option<&CompiledPrimaryGeometry>, + 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); + } + // 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) { + 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 +} + +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::Year + | DataType::YearMonth + | DataType::ControlledCode => { + declared.contains("CHAR") + || declared.contains("CLOB") + || declared.contains("TEXT") + || declared == "DATE" + || declared == "DATETIME" + } + } +} + +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, + 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( + 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}"), + OperationKind::Search { name } => format!("{resource}--search-{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 access_profile = &operation.access_profiles[0]; + let schema = first_artifacts + .artifacts + .iter() + .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); + } + + #[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 every_referenced_selector_codelist_must_be_in_the_governed_closure() { + let yaml = valid_contract() + .replace( + "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"); + 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() + .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 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"); + 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 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( + "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 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 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"); + 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 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"); + 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 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() + .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 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 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"); + + 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 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"); + 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.access_profiles[0].schema_reference = "https://elsewhere.invalid/schema".into(); + operation.access_profiles[0].semantic_model_reference = + "https://elsewhere.invalid/vocabulary".into(); + operation.access_profiles[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].access_profiles[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].access_profiles[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].access_profiles[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"); + 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 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 defaultAccessProfile: public\n accessProfiles:\n public: {access: public, disclosureProfile: public}", + &format!( + "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 + ), + ); + 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 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 defaultAccessProfile: public\n accessProfiles:\n public: {{access: {{scope: registry:records:lookup}}, 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 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("access_profiles object"); + let access_profile = access_profiles + .get("public") + .expect("public access profile") + .clone(); + for index in 1..=MAXIMUM_ACCESS_PROFILES_PER_OPERATION { + access_profiles.insert(format!("profile-{index}"), access_profile.clone()); + } + assert_refused( + &parse_value(access_profiles_value), + "access_profile.bound_exceeded", + ); + + let mut registry_access_profiles_value = + serde_json::to_value(&base).expect("contract serializes"); + let registry_access_profiles = registry_access_profiles_value + .pointer_mut("/resources/0/operations/read/accessProfiles") + .and_then(serde_json::Value::as_object_mut) + .expect("access_profiles object"); + let access_profile = registry_access_profiles + .get("public") + .expect("public access profile") + .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_access_profiles_value), + "access_profile.registry_bound_exceeded", + ); + + let list_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 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 an_operation_with_a_public_access_profile_requires_a_public_default() { + let contract = RegistryContract::parse_yaml(&valid_contract().replace( + "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) + .expect_err("a hidden protected default is refused"); + assert!(report + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "access_profile.public_default_required")); + } + + #[test] + 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("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_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); + 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_access_profiles_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 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) + .expect_err("unknown default refused"); + assert!(report + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "access_profile.default_invalid")); + + for characters in [0, MAXIMUM_PARTIAL_STRING_CHARACTERS + 1] { + let yaml = governed_access_profiles_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_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}", + ) + .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_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 = + 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( + "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_for(&contract), + ) + .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_profiles[0].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 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].access_profiles.len(), 1); + assert_eq!( + compiled.resources[0].operations[0].access_profiles[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_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 + .query + .spatial_bbox + .as_ref() + .expect("bbox") + .maximum_longitude_span_degrees, + 10 + ); + let access_profile = &operation.access_profiles[0]; + assert!(access_profile + .projected_columns + .iter() + .any(|column| column == "longitude")); + assert!(access_profile + .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.search.within-bbox".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 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"]["searches"][0]["accessProfiles"] + ["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"]["searches"][0]["query"] + ["maximumLatitudeSpanDegrees"] = serde_json::json!(181); + 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, "search.point_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"); + + 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_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); + let compiled = compile_contract_with_governed_files( + &contract, + &[spatial_observed_schema()], + CompileProfile::Production, + &governed_files, + ) + .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!(!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] + 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 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!({ + "searches": [{ + "id": "within-bbox", + "query": { + "kind": "point-bbox", + "maximumLongitudeSpanDegrees": 10, + "maximumLatitudeSpanDegrees": 10 + }, + "defaultAccessProfile": "public", + "accessProfiles": { + "public": { + "access": "public", + "disclosureProfile": "public" + } + }, + "orderBy": ["name"], + "pagination": {"defaultPageSize": 2, "maximumPageSize": 10} + }] + }); + value["resources"][0]["processingDescriptions"][0]["operationRefs"] = + serde_json::json!(["search:within-bbox"]); + } + 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 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"); + 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/legal-basis.yaml", + "status: reviewed\nbasis: statutory-publication\n", + ), + ( + "governance/review-rationale", + "reviewed classification and access profile design\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::(); + files.insert( + "governance/classification-review.yaml".into(), + review.into_bytes(), + ); + files + } + + fn governed_access_profiles_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 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}", + "metadataVisibility: {service: public, resources: operation-bound, semantics: operation-bound, classifications: operation-bound, processing: operation-bound}", + ) + } + + 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: + defaultAccessProfile: public + accessProfiles: + public: {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..3f5a27694 --- /dev/null +++ b/crates/registry-relay-v2/src/contract.rs @@ -0,0 +1,969 @@ +// 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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub primary_geometry: Option, + 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 transform: Option, + #[serde(default)] + 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 { + String, + Boolean, + 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 { + 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, + #[serde(default)] + pub searches: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ListOperation { + pub default_access_profile: String, + pub access_profiles: OrderedMap, + #[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 default_access_profile: String, + pub access_profiles: OrderedMap, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct LookupOperation { + pub id: String, + pub request_body: LookupRequestBody, + 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 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( + 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 AccessProfileDefinition { + 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 { + 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}" + ); + } + } + + #[test] + fn legacy_single_profile_operation_shape_is_not_accepted() { + let yaml = crate::compiler::tests::valid_contract().replace( + " 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 new file mode 100644 index 000000000..a7b09f9ef --- /dev/null +++ b/crates/registry-relay-v2/src/cursor.rs @@ -0,0 +1,478 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Client-opaque, confidential and 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 chacha20poly1305::aead::{Aead, KeyInit as _, Payload}; +use chacha20poly1305::{XChaCha20Poly1305, XNonce}; +use hmac::{Hmac, KeyInit, Mac}; +use serde::{Deserialize, Serialize}; +use sha2::Sha256; +use thiserror::Error; +use zeroize::Zeroizing; + +const CURSOR_VERSION: u8 = 2; +const MAX_CURSOR_BYTES: usize = 8 * 1024; +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; + +/// 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 access_profile: String, + pub disclosure_profile: String, + pub transforms_digest: String, + pub filters_digest: String, + 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 format_profile: Option, + 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 access_profile: String, + pub disclosure_profile: String, + pub transforms_digest: String, + 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, + access_profile: bindings.access_profile, + 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, + order_digest: bindings.order_digest, + bbox: None, + response_format: default_response_format(), + format_profile: None, + 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 + } + + #[must_use] + pub fn with_response_context( + mut self, + bbox: Option<[String; 4]>, + response_format: String, + format_profile: Option, + ) -> Self { + self.bbox = bbox; + self.response_format = response_format; + self.format_profile = format_profile; + self + } +} + +/// 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() < KEY_BYTES { + return Err(CursorError::Configuration); + } + 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 + /// 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 protection 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 plaintext = serde_json::to_vec(payload).map_err(|_| CursorError::Malformed)?; + if plaintext.is_empty() || plaintext.len() > MAX_CURSOR_BYTES { + return Err(CursorError::Malformed); + } + 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)) +} + +pub fn decode( + key: &CursorKey, + encoded: &str, + now_unix_seconds: u64, +) -> Result { + 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() <= ENVELOPE_OVERHEAD + || envelope.len() > MAX_CURSOR_BYTES + ENVELOPE_OVERHEAD + || envelope[0] != CURSOR_VERSION + { + return Err(CursorError::Malformed); + } + 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)?; + 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.access_profile != request.access_profile + || 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 + || cursor.order_digest != request.order_digest + || cursor.bbox != request.bbox + || cursor.response_format != request.response_format + || cursor.format_profile != request.format_profile + { + return Err(CursorError::Mismatch); + } + Ok(()) +} + +fn default_response_format() -> String { + "json".to_owned() +} + +#[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 { + access_profile: "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(), + order_digest: "sha256:order".to_owned(), + last_record_identifier: "record-1".to_owned(), + }, + ) + } + + #[test] + fn cursor_conceals_order_values_and_refuses_tampering() { + let key = CursorKey::new(vec![7; 32]).expect("key is sufficient"); + 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) + )); + } + + #[test] + 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.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] + fn cursor_cannot_cross_access_profile_disclosure_or_transform_contexts() { + let alterations: [fn(&mut CursorPayload); 3] = [ + |payload: &mut CursorPayload| payload.access_profile = "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) + ); + } + } + + #[test] + fn cursor_cannot_cross_spatial_or_format_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.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!( + 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 new file mode 100644 index 000000000..9009b8404 --- /dev/null +++ b/crates/registry-relay-v2/src/diff.rs @@ -0,0 +1,1356 @@ +// 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, + GeometryAdded, + GeometryRemoved, + GeometryChanged, + TransformationChanged, + HandlingRelaxed, + HandlingTightened, + OperationAdded, + OperationRemoved, + AccessProfileAdded, + AccessProfileRemoved, + DefaultAccessProfileChanged, + DisclosureExpanded, + DisclosureNarrowed, + DisclosureProfileChanged, + FilterAdded, + FilterRemoved, + FilterChanged, + SpatialQueryAdded, + SpatialQueryRemoved, + SpatialQueryExpanded, + SpatialQueryNarrowed, + SpatialQueryChanged, + UnfilteredEnabled, + UnfilteredDisabled, + SelectorChanged, + OrderingChanged, + PaginationExpanded, + PaginationNarrowed, + RequestBoundExpanded, + RequestBoundNarrowed, + ScopeChanged, + PurposeExpanded, + PurposeNarrowed, + RowBindingRemoved, + RowBindingAdded, + RowBindingChanged, + SourceViewChanged, + SourceSchemaChanged, + RecordContextChanged, + MetadataVisibilityRelaxed, + MetadataVisibilityTightened, + SemanticAlignmentChanged, + ClassificationChanged, + ClassificationReviewChanged, + 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", + ); + } + 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() + .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 !same_column_classifications(&previous.column_accounting, ¤t.column_accounting) { + push( + changes, + ChangeClass::ClassificationChanged, + ChangeImpact::Breaking, + format!("{root}.sourceColumnClassifications"), + "effective classifications 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", + ); + } + 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 + .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", + ); + } + 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 { + 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.default_access_profile != current.default_access_profile { + push( + changes, + ChangeClass::DefaultAccessProfileChanged, + ChangeImpact::Breaking, + format!("{location}.defaultAccessProfile"), + "the access profile selected when the caller omits an explicit choice changed", + ); + } + let before_access_profiles = previous + .access_profiles + .iter() + .map(|access_profile| (access_profile.id.as_str(), access_profile)) + .collect::>(); + let after_access_profiles = current + .access_profiles + .iter() + .map(|access_profile| (access_profile.id.as_str(), access_profile)) + .collect::>(); + for id in before_access_profiles + .keys() + .chain(after_access_profiles.keys()) + .collect::>() + { + let access_profile_location = format!("{location}.accessProfiles.{id}"); + match ( + before_access_profiles.get(*id), + after_access_profiles.get(*id), + ) { + (None, Some(_)) => push( + changes, + ChangeClass::AccessProfileAdded, + ChangeImpact::Widening, + access_profile_location, + "a callable access profile was added to the operation", + ), + (Some(_), None) => push( + changes, + ChangeClass::AccessProfileRemoved, + ChangeImpact::Breaking, + access_profile_location, + "a callable access profile was removed from the operation", + ), + (Some(before), Some(after)) => { + diff_access_profile(before, after, &access_profile_location, changes); + } + (None, None) => unreachable!(), + } + } + + 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", + ), + _ => {} + } + } + 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, + ) { + (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, + ); +} + +fn diff_access_profile( + previous: &crate::model::CompiledAccessProfile, + current: &crate::model::CompiledAccessProfile, + 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 access profile 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 access profile processing or disclosure handling floor changed", + ); + } + 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}.query"), + "an exact point bbox query was added", + ), + (Some(_), None) => push( + changes, + ChangeClass::SpatialQueryRemoved, + ChangeImpact::Breaking, + format!("{location}.query"), + "the exact point bbox query was removed", + ), + (Some(before), Some(after)) if before != after => { + let location = format!("{location}.query"); + 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, +) -> ( + &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 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>, + 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") + } + + 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)); + 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 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.access_profiles[0] + .transform_inventory + .push("partial-string:suffix:4".into()); + let mut alternate = operation.access_profiles[0].clone(); + alternate.id = "alternate".into(); + operation.access_profiles.push(alternate); + operation.default_access_profile = "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::AccessProfileAdded, + ChangeClass::DefaultAccessProfileChanged, + 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::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] + 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)); + } + + #[test] + 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); + 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] + .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_changes_do_not_masquerade_as_classification_changes() { + let added = compiled_spatial(); + 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)] + ); + } +} 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..a4f22fa43 --- /dev/null +++ b/crates/registry-relay-v2/src/fixture_contract.rs @@ -0,0 +1,133 @@ +// 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, + #[serde(default)] + pub geo_json_root: Option, + #[serde(default)] + pub geometry_type: Option, + #[serde(default)] + pub format_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 FixtureFormatProfile { + Rfc7946, + JsonFg, +} + +#[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 new file mode 100644 index 000000000..49f54ea39 --- /dev/null +++ b/crates/registry-relay-v2/src/fixtures.rs @@ -0,0 +1,1585 @@ +// 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, LINK, 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 tower::ServiceExt as _; + +use crate::auth::{FixturePrincipal, RelayAuthenticator}; +pub use crate::fixture_contract::{ + parse_journey, FixtureAuthorization, FixtureError, FixtureExpectation, FixtureFormatProfile, + FixtureGeoJsonRoot, FixtureGeometryType, 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; +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")] +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, +} + +/// 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(); + 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", + ); + } + } + 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( + &mut diagnostics, + "fixture.authorization_unknown", + &format!("steps[{index}].authorizationFixture"), + "the authorization fixture identifier is unknown", + ); + } + } + if !selected_steps.contains(&index) { + 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", + ); + } + 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( + &mut diagnostics, + "fixture.operation_unknown", + &format!("steps[{index}].request"), + "a successful fixture names no compiled operation", + ); + } + if let Some(operation) = operation { + let access_profile_identifier = step + .request + .query + .get("accessProfile") + .and_then(Value::as_str) + .unwrap_or(&operation.default_access_profile); + let protected = operation + .access_profiles + .iter() + .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( + &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, + } +} + +#[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( + 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(response_item_count); + 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 !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 = records + .first() + .and_then(|record| record.get("recordIdentifier")) + .and_then(Value::as_str); + if actual != Some(expected) { + mismatch( + diagnostics, + "fixture.record_mismatch", + &location, + "Record identity", + ); + } + } + 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 + .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.format_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_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.format_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.format_profile else { + return; + }; + if response + .headers + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + != Some("application/geo+json") + { + mismatch( + diagnostics, + "fixture.format_profile_mismatch", + location, + "GeoJSON content type", + ); + } + let (profile_uri, conformance) = match profile { + 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", + "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.format_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.format_profile_mismatch", + location, + "JSON-FG conformance", + ); + } + } else if document.get("conformsTo").is_some() || document.get("featureType").is_some() { + mismatch( + diagnostics, + "fixture.format_profile_mismatch", + location, + "RFC 7946 profile members", + ); + } +} + +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) + } + OperationKind::Search { name } => { + request.method == FixtureMethod::Get + && request.path == format!("/v2/resources/{}/searches/{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 geometries = response_geometries(document); + let mut records = response_records(document) + .into_iter() + .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 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"); + object.remove("@type"); + } + } + 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 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 + .get("items") + .and_then(Value::as_array) + .map_or_else(Vec::new, |items| items.iter().collect()) + } +} + +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", + "recordIdentifier", + "revisionIdentifier", + "lifecycleState", + "schemaReference", + "semanticModelReference", + "authorityIdentifier", + "recordedAt", + "domainData", + ] + .iter() + .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 + .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_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"); + assert_eq!(token.split('.').count(), 3); + 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#" +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 + formatProfile: 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.format_profile, + Some(FixtureFormatProfile::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" + formatProfile: 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/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 new file mode 100644 index 000000000..a212305c2 --- /dev/null +++ b/crates/registry-relay-v2/src/identification.rs @@ -0,0 +1,2703 @@ +// 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, SourceProfile, +}; +use crate::format_capabilities::{ + response_format_capabilities, FormatProfileIdentifier, WireFormatCapability, + WireFormatIdentifier, CRS84_URI, +}; +use crate::model::{ + CapabilityFamily, ColumnUse, CompiledAccess, CompiledAccessProfile, CompiledOperation, + CompiledRegistry, CompiledResource, CompiledTransform, ConsultationPattern, + EffectiveClassification, ObservedColumn, ObservedSourceSchema, OperationKind, + RowAuthoritySource, POINT_BBOX_PREDICATE, +}; + +pub const IDENTIFICATION_REPORT_PATH: &str = "reports/identification-report.json"; +pub const CLASSIFICATION_INVENTORY_REPORT_PATH: &str = "reports/classification-inventory.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"; +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 OperationExplanation { + pub api_version: String, + pub kind: String, + pub registry_identifier: String, + pub contract_revision: String, + pub classification_inventory_digest: String, + pub operations: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +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 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 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 access_profile_identifier: String, + pub is_default: bool, + 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, +} + +#[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 { + require_inventory_digest(registry, classification_inventory_digest)?; + let mut operations = registry + .resources + .iter() + .flat_map(|resource| { + resource + .operations + .iter() + .map(|operation| { + let mut access_profiles = operation + .access_profiles + .iter() + .map(|access_profile| AccessProfileExplanation { + 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), + 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: cache_posture(registry, resource, access_profile), + }, + }) + .collect::>(); + 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(), + operation_identifier: operation.identifier.clone(), + family: operation.family, + pattern: operation.pattern, + operation_kind: operation_kind(&operation.kind), + 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::>() + }) + .collect::>(); + 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(), + operations, + }) +} + +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> { + 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.access_profile_identifier, + if access_profile.is_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: POINT_BBOX_PREDICATE.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 { + 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 access_profile: 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 +/// access profile, 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 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 > access_profile.disclosure_handling) + }) + .collect::>(); + if !restrictive_selectors.is_empty() { + push_finding( + &mut findings, + "classification.context.selector_more_restrictive_than_disclosure", + resource, + Some(&operation.identifier), + Some(&access_profile.id), + access_profile.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) + && access_profile.disclosure_handling >= Handling::Confidential + { + push_finding( + &mut findings, + "classification.context.nonpublic_list_disclosure", + resource, + Some(&operation.identifier), + Some(&access_profile.id), + access_profile.selectable_properties.iter().cloned(), + std::iter::empty(), + "confidential or restricted data appears in a list access profile", + ); + } + 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| { + 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(&access_profile.id), + access_profile.selectable_properties.iter().cloned(), + hidden_nonpublic, + "a public access profile processes hidden non-public source columns", + ); + } + } + } + } + } + findings.sort_by(|left, right| { + left.resource + .cmp(&right.resource) + .then(left.operation.cmp(&right.operation)) + .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)) + }); + 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 (_, 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 (_, access_profile) in operation.access_profiles.iter() { + add_access_roles(&mut hints, source, view, &access_profile.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 (_, 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); + } + } + } + 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}"), + OperationKind::Search { name } => format!("search:{name}"), + } +} + +fn processed_columns( + operation: &CompiledOperation, + access_profile: &CompiledAccessProfile, +) -> Vec { + let mut columns = access_profile + .projected_columns + .iter() + .cloned() + .collect::>(); + columns.extend( + operation + .query + .filters + .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 + .query + .selectors + .iter() + .map(|selector| selector.source_column.clone()), + ); + if let CompiledAccess::Protected { + row_binding: Some(binding), + .. + } = &access_profile.access + { + columns.insert(binding.source_column.clone()); + } + columns.into_iter().collect() +} + +fn disclosed_source_columns( + resource: &CompiledResource, + access_profile: &CompiledAccessProfile, +) -> 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 &access_profile.selectable_properties { + if let Some(property) = resource + .properties + .iter() + .find(|property| property.name == *name && property.transform.is_none()) + { + 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 +} + +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>, + access_profile: 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), + 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(), + }); +} + +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::*; + 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() { + 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) + ); + } + + #[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); + 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.access_profile_identifier == "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.access_profile_identifier == "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)); + } + + 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/lib.rs b/crates/registry-relay-v2/src/lib.rs new file mode 100644 index 000000000..ddcf680f2 --- /dev/null +++ b/crates/registry-relay-v2/src/lib.rs @@ -0,0 +1,34 @@ +// 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; +pub mod auth; +pub mod compiler; +pub mod contract; +pub mod cursor; +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; +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 mod transform; + +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/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..6bc166566 --- /dev/null +++ b/crates/registry-relay-v2/src/model.rs @@ -0,0 +1,454 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Immutable compiled model and rendering-neutral reports. + +use serde::{Deserialize, Serialize}; + +use crate::contract::{ + AlignmentTarget, DataType, DateInputType, DatePrecision, Handling, IdentificationMethod, + PartialStringReveal, 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 classification_review: Option, + 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 primary_geometry: Option, + 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 transform: Option, + 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(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 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 { + 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 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 { + pub identifier: String, + pub family: CapabilityFamily, + pub pattern: ConsultationPattern, + pub kind: OperationKind, + 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 CompiledAccessProfile { + pub id: String, + pub access: CompiledAccess, + pub disclosure_profile: String, + pub selectable_properties: Vec, + 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, +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "kebab-case")] +pub enum FormatProfile { + Rfc7946, + JsonFg, +} + +#[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 }, + Search { 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 filters: Vec, + pub spatial_bbox: Option, + 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 CompiledSpatialBboxQuery { + pub longitude_column: String, + pub latitude_column: String, + pub maximum_longitude_span_degrees: u16, + 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 { + 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), + GeometryLongitude(String), + GeometryLatitude(String), + SpatialBbox(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..f00c1eb47 --- /dev/null +++ b/crates/registry-relay-v2/src/package.rs @@ -0,0 +1,1443 @@ +// 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, 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/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; +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 operation_artifact_bindings: 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 access_profile_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, + compiled.classification_review.as_ref(), + )?; + 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, + "application/yaml", + Visibility::OperatorOnly, + false, + )); + for (relative, content) in &authored { + files.push(file_entry( + &format!("governed/{relative}"), + content, + media_type(relative), + Visibility::OperatorOnly, + 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), + &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(), + access_profile_identifier: artifact.access_profile_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, + operation_artifact_bindings: &artifacts.operation_bindings, + 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, + operation_artifact_bindings: artifacts.operation_bindings.clone(), + 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)?; + } + write_new_file(&output_dir.join(COMPILED_REGISTRY_PATH), &compiled_bytes)?; + 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) +} + +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)?; + verify_artifact_derivation(compiled, artifacts)?; + 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 { + 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() + .zip(artifact.access_profile_identifier.as_deref()) + .is_some_and(|pair| !expected_operation_access_profiles.contains(&pair)) + || artifact.operation_identifier.is_some() + != artifact.access_profile_identifier.is_some() + { + return Err(PackageError::Verification); + } + } + if !valid_operation_artifact_bindings( + &artifacts.operation_bindings, + &expected_operation_access_profiles, + &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(()) +} + +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 { + 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, + operation_artifact_bindings: &manifest.operation_artifact_bindings, + 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)?; + 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 compiled_entry = manifest + .files + .iter() + .filter(|entry| entry.path == COMPILED_REGISTRY_PATH) + .collect::>(); + 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::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() + .map(|source| { + ( + source.id.clone(), + source.expected_schema_fingerprint.clone(), + ) + }) + .collect::>() + != manifest.source_schema_fingerprints + { + return Err(PackageError::Verification); + } + + let governed = loaded + .iter() + .filter_map(|(path, content)| { + path.strip_prefix("governed/") + .map(|relative| (relative.to_owned(), content.clone())) + }) + .collect::(); + let observed = manifest + .source_schemas + .values() + .cloned() + .collect::>(); + 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 expected_operation_access_profiles = operation_access_profile_pairs(®istry); + 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 relative_path.is_empty() + || !artifact_ids.insert(artifact.id.as_str()) + || !artifact_paths.insert(relative_path) + || artifact + .operation_identifier + .as_deref() + .zip(artifact.access_profile_identifier.as_deref()) + .is_some_and(|pair| !expected_operation_access_profiles.contains(&pair)) + || artifact.operation_identifier.is_some() + != artifact.access_profile_identifier.is_some() + { + return Err(PackageError::Verification); + } + 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(), + access_profile_identifier: artifact.access_profile_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, + &expected_operation_access_profiles, + &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(), + }; + verify_artifact_derivation(®istry, &artifacts)?; + 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], + operation_artifact_bindings: &'a [OperationArtifactBindings], + files: &'a [PackageFile], +} + +fn valid_operation_artifact_bindings( + bindings: &[OperationArtifactBindings], + expected_operation_access_profiles: &BTreeSet<(&str, &str)>, + artifact_paths: &BTreeSet<&str>, +) -> bool { + let mut bound_operation_access_profiles = BTreeSet::new(); + for binding in bindings { + let pair = ( + binding.operation_identifier.as_str(), + binding.access_profile_identifier.as_str(), + ); + if !expected_operation_access_profiles.contains(&pair) + || !bound_operation_access_profiles.insert(pair) + || [ + binding.vocabulary_path.as_str(), + binding.context_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(), + ] + .iter() + .any(|path| !artifact_paths.contains(path)) + { + return false; + } + } + bound_operation_access_profiles == *expected_operation_access_profiles +} + +fn operation_access_profile_pairs(registry: &CompiledRegistry) -> BTreeSet<(&str, &str)> { + registry + .resources + .iter() + .flat_map(|resource| resource.operations.iter()) + .flat_map(|operation| { + operation + .access_profiles + .iter() + .map(|access_profile| (operation.identifier.as_str(), access_profile.id.as_str())) + }) + .collect() +} + +fn capture_governed_closure( + project_root: &Path, + contract: &RegistryContract, + review: Option<&CompiledClassificationReview>, +) -> Result>, PackageError> { + 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()); + } + } + 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::*; + + 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()); + assert!(validate_relative("../outside.yaml").is_err()); + assert!(validate_relative("/absolute.yaml").is_err()); + } + + #[test] + fn multi_access_profile_package_bindings_are_exactly_closed() { + let yaml = crate::compiler::tests::valid_contract() + .replace( + "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"); + 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_access_profile_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_access_profile = cross_operation + .iter() + .find(|binding| binding.operation_identifier.ends_with(".list")) + .expect("list binding") + .access_profile_identifier + .clone(); + let read_binding = cross_operation + .iter_mut() + .find(|binding| binding.operation_identifier.ends_with(".read")) + .expect("read binding"); + read_binding.access_profile_identifier = listing_access_profile; + 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() { + 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, None), + 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 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 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!( + 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"); + // 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); + 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 + ); + 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(), + &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"); + 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"); + 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..c12490172 --- /dev/null +++ b/crates/registry-relay-v2/src/problem.rs @@ -0,0 +1,422 @@ +// 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, + AccessProfileInvalid, + MissingCredential, + InvalidCredential, + ConsultationDenied, + ResourceNotFound, + ConsultationUnresolved, + UnsupportedFormat, + 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::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::UnsupportedFormat => "format.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::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::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", + 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 + | Self::AccessProfileInvalid => 400, + Self::MissingCredential | Self::InvalidCredential => 401, + Self::ConsultationDenied => 403, + Self::ResourceNotFound | Self::ConsultationUnresolved => 404, + Self::UnsupportedFormat => 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::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::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", + 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 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()); + 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..549aeeb87 --- /dev/null +++ b/crates/registry-relay-v2/src/semantics.rs @@ -0,0 +1,669 @@ +// 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::{CompiledPrimaryGeometry, 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, + })); + } + 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#", + "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)); + context.insert("xsd".into(), json!("http://www.w3.org/2001/XMLSchema#")); + for field in [ + "registryIdentifier", + "schemaReference", + "semanticModelReference", + "authorityIdentifier", + ] { + context.insert( + field.into(), + 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(), + 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", + "@type": datatype_iri(property.data_type), + }), + ); + } + // 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}) +} + +pub fn access_profile_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()) + .chain( + resource + .primary_geometry + .iter() + .map(|geometry| geometry.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 = + &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) { + 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())); + } + } + 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, + "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": { + "@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}, + "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 + } + }) +} + +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 access_profile_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()) + .chain( + resource + .primary_geometry + .iter() + .map(|geometry| geometry.name.clone()), + ) + .collect::>(); + shacl(registry, resource, &selected, true) +} + +fn shacl( + registry: &CompiledRegistry, + resource: &CompiledResource, + 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 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 [ + ( + "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 controlled_values = if path == "lifecycleState" { + lifecycle_constraint.as_str() + } else { + "" + }; + output.push_str(&format!( + " ;\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 = 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, + datatype_iri(property.data_type), + controlled_values, + 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 +} + +fn property_schema(registry: &CompiledRegistry, property: &CompiledProperty) -> Value { + match property.data_type { + DataType::String => json!({"type": "string"}), + DataType::ControlledCode => { + 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"}), + 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" + }), + } +} + +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('\\', "\\\\") + .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", + DataType::Year => "http://www.w3.org/2001/XMLSchema#gYear", + DataType::YearMonth => "http://www.w3.org/2001/XMLSchema#gYearMonth", + } +} + +fn selected_properties<'a>( + resource: &'a CompiledResource, + selected: &[String], +) -> Vec<&'a CompiledProperty> { + resource + .properties + .iter() + .filter(|property| selected.contains(&property.name)) + .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::*; + + #[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"]["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] + #[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"]) + ); + 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\" )")); + } + + 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(), + } + } + + #[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 = access_profile_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 = access_profile_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::*; + 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(), + transform: None, + 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(), + }, + }], + primary_geometry: None, + 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(), + classification_review: None, + 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..940f3db13 --- /dev/null +++ b/crates/registry-relay-v2/src/server.rs @@ -0,0 +1,396 @@ +// 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/resources/{resource}/searches/{search}", + get(crate::api::record_search), + ) + .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("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() => + { + "/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, 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(operation.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")); + 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" + ); + } + + #[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..49017bfe6 --- /dev/null +++ b/crates/registry-relay-v2/src/sqlite_runtime.rs @@ -0,0 +1,859 @@ +// 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::{ + CompiledAccessProfile, 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, + 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)] +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, + access_profile: CompiledAccessProfile, + source_revision: SourceRevision, +} + +struct OperationInventory { + source_revision: SourceRevision, + access_profiles: BTreeMap, +} + +#[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 mut access_profiles = BTreeMap::new(); + for access_profile in &operation.access_profiles { + let contract = statement_contract( + resource, + operation, + access_profile, + &limits, + &source.expected_schema_fingerprint, + )?; + let statement = ReadOnlyStatement::open(profile.clone(), contract)?; + if access_profiles + .insert( + access_profile.id.clone(), + OperationExecutor { + statement: Arc::new(statement), + operation: operation.clone(), + access_profile: access_profile.clone(), + source_revision: source_revision.clone(), + }, + ) + .is_some() + { + return Err(SqliteRuntimeError::InvalidPlan); + } + } + if access_profiles.is_empty() + || operations + .insert( + operation.identifier.clone(), + OperationInventory { + source_revision: source_revision.clone(), + access_profiles, + }, + ) + .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, + access_profile: &str, + query: OperationQuery, + ) -> Result { + let executor = self + .operations + .get(operation) + .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.access_profile, 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, + access_profile: &CompiledAccessProfile, + limits: &SqliteRuntimeLimits, + expected_schema_fingerprint: &str, +) -> Result { + let result_columns = result_columns(operation, access_profile); + 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 | OperationKind::Search { .. } => { + collection_sql(operation, access_profile, &result_columns, &mut parameters) + } + OperationKind::Read => read_sql( + resource, + operation, + access_profile, + &result_columns, + &mut parameters, + ), + OperationKind::Lookup { .. } => { + lookup_sql(operation, access_profile, &result_columns, &mut parameters) + } + }; + let maximum_rows = match &operation.kind { + OperationKind::List | OperationKind::Search { .. } => 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. Each fixed + // access_profile has one connection, and compilation bounds the + // Registry-wide access_profile executor inventory. + 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, + access_profile: &CompiledAccessProfile, +) -> Vec { + let mut columns = access_profile.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 { + if resource.primary_geometry.as_ref().is_some_and(|geometry| { + geometry.longitude_column == column || geometry.latitude_column == column + }) { + return ColumnType::Number; + } + 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::Year + | DataType::YearMonth + | DataType::ControlledCode => ColumnType::String, + } +} + +fn collection_sql( + operation: &CompiledOperation, + access_profile: &CompiledAccessProfile, + 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) + )); + } + if let Some(spatial) = &operation.query.spatial_bbox { + for name in ["bbox_west", "bbox_south", "bbox_east", "bbox_north"] { + parameters.push(parameter(name)); + } + predicates.push(format!( + "({} >= :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(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}))")); + 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, + access_profile: &CompiledAccessProfile, + 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(access_profile, 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, + access_profile: &CompiledAccessProfile, + 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(access_profile, parameters, &mut predicates); + format!( + "SELECT {} FROM {} WHERE {} LIMIT 2", + select_list(columns), + quote_identifier(&operation.query.view), + predicates.join(" AND ") + ) +} + +fn add_row_authority( + access_profile: &CompiledAccessProfile, + parameters: &mut Vec, + predicates: &mut Vec, +) { + if let crate::model::CompiledAccess::Protected { + row_binding: Some(binding), + .. + } = &access_profile.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, + access_profile: &CompiledAccessProfile, + query: OperationQuery, +) -> Result, SqliteRuntimeError> { + let mut values = BTreeMap::new(); + match &operation.kind { + OperationKind::List | OperationKind::Search { .. } => { + 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)); + } + 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_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)); + } + (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() { + 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 => { + if query.bbox.is_some() { + return Err(SqliteRuntimeError::InvalidPlan); + } + values.insert( + "record_identifier".into(), + Value::String( + query + .record_identifier + .ok_or(SqliteRuntimeError::InvalidPlan)?, + ), + ); + } + OperationKind::Lookup { .. } => { + if query.bbox.is_some() { + return Err(SqliteRuntimeError::InvalidPlan); + } + 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), + .. + } = &access_profile.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) +} + +#[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() { + 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()); + } + + #[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 new file mode 100644 index 000000000..b8313983a --- /dev/null +++ b/crates/registry-relay-v2/src/startup.rs @@ -0,0 +1,1088 @@ +// 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_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| { + resource + .operations + .list + .iter() + .flat_map(|operation| { + operation + .access_profiles + .iter() + .map(|(_, item)| &item.access) + }) + .chain(resource.operations.read.iter().flat_map(|operation| { + operation + .access_profiles + .iter() + .map(|(_, item)| &item.access) + })) + .chain(resource.operations.lookups.iter().flat_map(|operation| { + operation + .access_profiles + .iter() + .map(|(_, item)| &item.access) + })) + .chain(resource.operations.searches.iter().flat_map(|operation| { + operation + .access_profiles + .iter() + .map(|(_, item)| &item.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_paginated_operations_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: {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", + ) + .expect("closed runtime"); + assert_eq!( + validate_runtime_contract(&protected_runtime, &protected), + Err(StartupError::IssuerUnavailable) + ); + + let list = contract( + "{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", + ) + .expect("closed runtime"); + assert_eq!( + validate_runtime_contract(&list_runtime, &list), + 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}}}, 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", + ) + .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..162eaef40 --- /dev/null +++ b/crates/registry-relay-v2/src/tooling.rs @@ -0,0 +1,1443 @@ +// 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 sha2::{Digest, Sha256}; +use thiserror::Error; + +use crate::artifacts::{generate_artifacts, ArtifactSet}; +use crate::audit::RelayAudit; +use crate::compiler::{ + classification_inventory_digest, compile_contract_with_governed_files, + referenced_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, 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, +}; +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, + pub explain: 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, + #[serde(default, skip_serializing_if = "Option::is_none")] + operation_explanation: 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 authoring explanation could not be constructed")] + Explain, + #[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::Explain => "the authoring explanation 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 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)?, + ), + 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), + operation_explanation, + })) + } + ProjectCompilation::Refused(report) => Ok(ToolingReport::refused( + report.diagnostics, + ToolingDetails::Check { + contract_revision: None, + production: options.production, + configuration_key_paths: None, + operation_explanation: 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.list.accessProfiles" + | "resources[].operations.read.accessProfiles" + | "resources[].operations.lookups[].accessProfiles" + | "resources[].operations.searches[].accessProfiles" + | "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 CompiledProject { + contract, + registry, + observed, + .. + } = *project; + let artifacts = generate_artifacts(®istry).map_err(|_| ToolingError::Generate)?; + let classification_digest = + classification_inventory_digest(®istry).map_err(|_| ToolingError::Generate)?; + let identification = + identify_contract(&contract, &observed).map_err(|_| ToolingError::Generate)?; + let inventory = classification_inventory_report(®istry, &classification_digest) + .map_err(|_| ToolingError::Generate)?; + let operation_explanation = operation_explanation(®istry, &classification_digest) + .map_err(|_| ToolingError::Generate)?; + let findings = contextual_review_findings(®istry, &classification_digest) + .map_err(|_| ToolingError::Generate)?; + let starter = classification_review_starter(&contract, &classification_digest, &identification) + .map_err(|_| ToolingError::Generate)?; + let authoring_outputs = [ + ( + "identification-report", + IDENTIFICATION_REPORT_PATH, + render_identification_report(&identification).map_err(|_| ToolingError::Generate)?, + ), + ( + "classification-inventory", + CLASSIFICATION_INVENTORY_REPORT_PATH, + render_classification_inventory_report(&inventory) + .map_err(|_| ToolingError::Generate)?, + ), + ( + "operation-explanation", + OPERATION_EXPLANATION_PATH, + render_operation_explanation(&operation_explanation) + .map_err(|_| ToolingError::Generate)?, + ), + ( + "contextual-review-findings", + CONTEXTUAL_REVIEW_FINDINGS_PATH, + render_contextual_review_findings(&findings).map_err(|_| ToolingError::Generate)?, + ), + ( + "classification-review-starter", + CLASSIFICATION_REVIEW_STARTER_PATH, + render_classification_review_yaml(&starter).map_err(|_| ToolingError::Generate)?, + ), + ]; + let output = options + .output_dir + .clone() + .unwrap_or_else(|| options.project_root.join("generated")); + write_artifacts(&output, &artifacts)?; + let mut generated = artifacts + .artifacts + .iter() + .map(|artifact| GeneratedFile { + id: artifact.id.clone(), + path: artifact.path.clone(), + sha256: artifact.sha256.clone(), + }) + .collect::>(); + 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, + })) +} + +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, + observed: Vec, +} + +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, + observed, + }))) + } + 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 = 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)?; + 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)?; + reject_existing_symlink_components(&canonical_root, Path::new(&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, + 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().any(|resource| { + resource.operations.list.is_some() || !resource.operations.searches.is_empty() + }) && runtime.cursor.is_none() + { + diagnostics.push(diagnostic( + "runtime.cursor_missing", + "runtime.yaml.cursor", + "a Registry with a list or search operation requires an opaque-cursor key and age bound", + )); + } + let protected = contract.resources.iter().any(|resource| { + resource + .operations + .list + .iter() + .flat_map(|operation| { + operation + .access_profiles + .iter() + .map(|(_, item)| &item.access) + }) + .chain(resource.operations.read.iter().flat_map(|operation| { + operation + .access_profiles + .iter() + .map(|(_, item)| &item.access) + })) + .chain(resource.operations.lookups.iter().flat_map(|operation| { + operation + .access_profiles + .iter() + .map(|(_, item)| &item.access) + })) + .chain(resource.operations.searches.iter().flat_map(|operation| { + operation + .access_profiles + .iter() + .map(|(_, item)| &item.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 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() { + 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: + defaultAccessProfile: default + accessProfiles: + 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} +"#; + +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 = 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 = + "id: record-lifecycle\nversion: draft-1\nvalues: [ACTIVE, RETIRED]\nstatus: suggested\n"; + +#[cfg(test)] +mod tests { + use super::*; + + fn generic_project() -> (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 [ + ToolingError::Read, + ToolingError::Write, + ToolingError::UnsafePath, + ToolingError::Inspect, + ToolingError::Generate, + ToolingError::Explain, + 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 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 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"); + 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("-"); + 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"); + } + } + + #[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/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 new file mode 100644 index 000000000..8fb39d6ab --- /dev/null +++ b/crates/registry-relay-v2/tests/acceptance_http.rs @@ -0,0 +1,2754 @@ +// 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, Mutex}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use axum::body::{to_bytes, Body}; +use bytes::Bytes; +use futures::stream; +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; +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, ArtifactSet}; +use registry_relay_v2::audit::RelayAudit; +use registry_relay_v2::auth::RelayAuthenticator; +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::fixture_contract::{ + parse_journey, FixtureAuthorization as AuthorizationFixture, + 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, +}; +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_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(Default)] +struct ResponseContractCoverage { + json_records: usize, + json_ld_records: usize, +} + +struct ProjectHarness { + app: axum::Router, + service: Arc, + artifacts: Arc, + contract: RegistryContract, + runtime: RelayRuntime, + database: PathBuf, + idp: Option, + _temp: TempDir, +} + +struct ControlledAuditSink { + fail_on_write: usize, + writes: AtomicUsize, + records: Mutex>, +} + +impl ControlledAuditSink { + fn new(fail_on_write: usize) -> Self { + 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> { + 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(()) + } + + #[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 = project_journey(project); + 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(); + let mut contract_coverage = ResponseContractCoverage::default(); + 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; response body withheld", + step.id + ); + 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"); + 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) + .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); + } + } + 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 + .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; + } + } +} + +#[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', 'Invalid Fixture Enterprise'", + "X'FF', X'FF'", + 1, + ); + let missing_required = valid_recorded_at + .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), + ("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, + 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 = project_journey("social-assistance"); + 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 = project_journey("social-assistance"); + 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 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; + 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}?formatProfile=rfc7946"), + Some("application/geo+json"), + None, + ) + .await; + let (json_fg_headers, json_fg) = successful_get( + &harness, + &format!("{path}?formatProfile=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--access-profile-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--access-profile-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--access-profile-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 format has an ETag") + }) + .collect::>(); + assert_eq!(etags.len(), 4, "each exact format 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}?formatProfile=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?formatProfile=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; + let journey = project_journey("social-assistance"); + 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 = project_journey("social-assistance"); + 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--access-profile-limited-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( + "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 + .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(), + 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( + "civil-event", + Some(Arc::clone(&failing_sink) as Arc), + ) + .await; + assert_problem_code( + harness + .app + .oneshot( + Request::builder() + .uri("/v2/resources/civil-event/records/EVENT-SYNTH-0001") + .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 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)); + let harness = ProjectHarness::open_with_audit( + "civil-event", + Some(Arc::clone(&sink) as Arc), + ) + .await; + let journey = project_journey("civil-event"); + 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; + let journey = project_journey("social-assistance"); + 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 +} + +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)); +} + +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.unwrap_or(false) { + 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.unwrap_or(false) { + 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") + .or_else(|| document.get("features")) + .and_then(Value::as_array) + .map(Vec::len), + 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() { + Some("non-null") => assert!( + cursor.is_some_and(|value| !value.is_null()), + "{label} cursor" + ), + 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.unwrap_or(false) { + 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 !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!( + 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" => { + 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 { + 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 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.format_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 { + 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", + "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() + .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"); + } + serde_json::json!({"record": record, "geometry": geometry}) + }) + .collect() +} + +fn response_records(document: &Value) -> Vec<&Value> { + 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 + .get("items") + .and_then(Value::as_array) + .map_or_else(Vec::new, |items| items.iter().collect()) + } +} + +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" | "application/geo+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 access_profile_identifier = document + .pointer("/meta/accessProfile") + .and_then(Value::as_str) + .expect("Record response names its selected access profile"); + let matching_bindings = harness + .service + .artifacts + .operation_bindings + .iter() + .filter(|binding| { + binding.operation_identifier == operation_identifier + && binding.access_profile_identifier == access_profile_identifier + }) + .collect::>(); + assert_eq!( + matching_bindings.len(), + 1, + "{project}/{} must resolve one exact operation and access-profile 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-access-profile 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-access-profile 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-access-profile schema must compile", + step.id + ) + }); + assert!( + validator.is_valid(record), + "{project}/{} Record must validate against its exact generated permitted-access-profile schema", + step.id + ); + + assert_eq!( + 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.access_profile_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 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 access_profile = resource + .operations + .iter() + .find(|operation| operation.identifier == binding.operation_identifier) + .and_then(|operation| { + operation + .access_profiles + .iter() + .find(|access_profile| access_profile.id == binding.access_profile_identifier) + }) + .expect("compiled operation carries the selected access profile"); + assert_eq!( + document.get("@context").and_then(Value::as_str), + 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(access_profile.context_reference.as_str()), + "{project}/{} response metadata must name the selected access profile context", + step.id + ); + 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 shacl = std::str::from_utf8( + &harness + .service + .artifacts + .get(&binding.access_profile_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() { + if let Some(geometry) = resource + .primary_geometry + .as_ref() + .filter(|geometry| geometry.name == *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, + 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() + .find(|property| property.name == *property_name) + .expect("disclosed property is compiled"); + assert!(access_profile.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 + } + + async fn open_with_audit(project: &str, sink: Option>) -> Self { + let root = project_root(project); + 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, &fixture_sql).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(); + 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, + 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 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, + &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, + Arc::clone(&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, + artifacts, + 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 = 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_empty() { + 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()); + 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()); + } + 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 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()); + } + } + 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 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()), + 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/access_profile_http.rs b/crates/registry-relay-v2/tests/access_profile_http.rs new file mode 100644 index 000000000..0585ad425 --- /dev/null +++ b/crates/registry-relay-v2/tests/access_profile_http.rs @@ -0,0 +1,1215 @@ +// 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, 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, +}; +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:access_profiles"; + +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 access-profile 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 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"); + + let (status, _, body) = harness + .send( + Method::GET, + "/v2/resources/record/records/record-1?accessProfile=missing", + Some("not-a-jwt"), + None, + &[], + ) + .await; + assert_problem( + status, + &body, + StatusCode::UNAUTHORIZED, + "auth.invalid_credential", + ); + + for (token, access_profile, expected_status, expected_code) in [ + ( + None, + "caseworker", + StatusCode::NOT_FOUND, + "resource.not_found", + ), + (None, "missing", StatusCode::NOT_FOUND, "resource.not_found"), + ( + Some(limited.as_str()), + "caseworker", + StatusCode::NOT_FOUND, + "resource.not_found", + ), + ( + Some(limited.as_str()), + "missing", + StatusCode::NOT_FOUND, + "resource.not_found", + ), + ] { + 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); + } + + let records = sink.values(); + assert!(records.iter().all(|event| event["phase"] == "refusal")); + assert!(records + .iter() + .all(|event| event.get("accessProfile").is_none())); + assert_eq!( + records + .iter() + .filter(|event| event.get("accessProfile").is_none()) + .count(), + 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_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 access_profile in ["caseworker", "missing"] { + let uri = format!( + "/v2/resources/record/records/record-1?accessProfile={access_profile}&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()); + 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?accessProfile=", + StatusCode::BAD_REQUEST, + "request.access_profile_invalid", + ), + ( + "/v2/resources/record/records?accessProfile=limited&accessProfile=caseworker", + StatusCode::BAD_REQUEST, + "request.access_profile_invalid", + ), + ( + "/v2/resources/record/records?accessProfile=missing", + StatusCode::NOT_FOUND, + "resource.not_found", + ), + ( + "/v2/resources/record/records?accessProfile=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?accessProfile=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_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?accessProfile=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"]["accessProfile"], "limited"); + assert_eq!(document["meta"]["selectedFields"], json!(["maskedSecret"])); + + let (status, _, body) = harness + .send( + Method::GET, + "/v2/resources/record/records/record-1?accessProfile=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_access_profile() { + 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?accessProfile=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?accessProfile=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?accessProfile=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_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); + 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.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 + .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::SERVICE_UNAVAILABLE, + "source.unavailable", + ); + + 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"], "source-failed"); + 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?accessProfile=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["accessProfile"], "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}?accessProfile=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?accessProfile=limited", + Some(&limited), + Some(json!({"selectors": {"lookupKey": "lookup-3"}})), + &[], + ) + .await; + assert_problem( + status, + &body, + StatusCode::SERVICE_UNAVAILABLE, + "source.unavailable", + ); + 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?accessProfile=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_access_profiles() { + 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?accessProfile=public", + None, + None, + &[], + ) + .await; + assert_eq!(status, StatusCode::OK); + let (status, _, body) = harness + .send( + Method::GET, + "/v2/resources/record/records/record-1?accessProfile=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?accessProfile=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?accessProfile=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 = access_profile( + "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 = access_profile( + "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 = access_profile( + "caseworker", + protected_access("registry:caseworker"), + "caseworker-disclosure", + &["secretValue"], + &core_columns + .into_iter() + .chain(["secret_value"]) + .collect::>(), + Handling::Restricted, + Handling::Restricted, + &[], + ); + 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_access_profile: "public".into(), + access_profiles: access_profiles.clone(), + query: QueryPlan { + 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, + 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_access_profile: "public".into(), + access_profiles: access_profiles.clone(), + query: QueryPlan { + source: SOURCE.into(), + view: "relay_records".into(), + filters: Vec::new(), + spatial_bbox: None, + 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_access_profile: "public".into(), + access_profiles, + query: QueryPlan { + source: SOURCE.into(), + view: "relay_records".into(), + filters: Vec::new(), + spatial_bbox: None, + 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: "access_profile-tests".into(), + contract_version: "1".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 access-profile 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(), + primary_geometry: None, + 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 access_profile( + id: &str, + access: CompiledAccess, + disclosure_profile: &str, + selectable: &[&str], + projected: &[&str], + processing: Handling, + disclosure: Handling, + transforms: &[&str], +) -> CompiledAccessProfile { + let stem = format!("https://registry.example.invalid/artifacts/{id}"); + CompiledAccessProfile { + 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/crates/registry-relay-v2/tests/identification.rs b/crates/registry-relay-v2/tests/identification.rs new file mode 100644 index 000000000..470bcc6fb --- /dev/null +++ b/crates/registry-relay-v2/tests/identification.rs @@ -0,0 +1,684 @@ +// 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, operation_explanation, + parse_classification_review_yaml, render_classification_inventory_report, + render_classification_review_yaml, render_contextual_review_findings, + render_identification_report, render_operation_explanation, render_operation_explanation_text, + 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.access_profiles[0].disclosure_handling = + registry_relay_v2::contract::Handling::Confidential; + operation.access_profiles[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 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!( + 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 = &explanation.operations[0].access_profiles[0]; + assert!(boundary + .processing + .source_columns + .contains(&"region_code".into())); + assert!(boundary.disclosure.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_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, + 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: + defaultAccessProfile: default + accessProfiles: + 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 new file mode 100644 index 000000000..90f594c84 --- /dev/null +++ b/crates/registry-relay-v2/tests/multi_resource_isolation.rs @@ -0,0 +1,1150 @@ +// 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::{ + 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, + 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: + defaultAccessProfile: public + accessProfiles: + public: {access: public, disclosureProfile: public-view} + filters: [] + allowUnfiltered: true + orderBy: [publicIdentifier] + pagination: {defaultPageSize: 1, maximumPageSize: 1} + read: + defaultAccessProfile: public + accessProfiles: + public: {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: + defaultAccessProfile: protected + accessProfiles: + 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: + defaultAccessProfile: protected + accessProfiles: + 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 + requestBody: + maximumBytes: 128 + selectors: + lookupKey: {sourceColumn: lookup_key, type: string, minimumBytes: 1, maximumBytes: 32} + defaultAccessProfile: protected + accessProfiles: + 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] + 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); + let access_profile = list + .access_profiles + .iter() + .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 + .as_ref() + .expect("list has pagination") + .maximum_page_size, + page_maximum + ); + match (&access_profile.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!(access_profile.schema_reference.contains(resource_id)); + assert!(access_profile + .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::NOT_FOUND, + "resource.not_found", + ); + 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 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(), + b"kind: synthetic-policy\n".to_vec(), + ), + ( + "governance/classification-provenance.yaml".into(), + review.into_bytes(), + ), + ( + "governance/classification-review-rationale.md".into(), + b"Synthetic multi-resource classification review.\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}; response body withheld") + }); + (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 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"] { + assert!(!text.contains(absent), "problem disclosed {absent}"); + } +} + +fn assert_success(response: (StatusCode, Value)) -> Value { + assert_eq!(response.0, StatusCode::OK, "response body withheld"); + 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..8460c3da0 --- /dev/null +++ b/crates/registry-relay-v2/tests/process_http.rs @@ -0,0 +1,382 @@ +// 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::{ + 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; + +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"); + make_business_project_public_only(&etc, &source); + + 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(); + 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") + .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: {report:?}" + ); + + 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" + ); +} + +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, 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 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"), + ) + .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; + + 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..3d7b9ab45 --- /dev/null +++ b/crates/registry-relayctl/INTEGRATION.md @@ -0,0 +1,44 @@ +# 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 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; +- 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. + +`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 new file mode 100644 index 000000000..d86e78fef --- /dev/null +++ b/crates/registry-relayctl/src/lib.rs @@ -0,0 +1,476 @@ +// 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 registry_relay_v2::identification::render_operation_explanation_text; +use registry_relay_v2::tooling::{ToolingDetails, ToolingReport}; +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, + + /// Explain compiled operations, access, disclosure, and wire formats. + #[arg(long)] + explain: 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 selected fixture and its declared prerequisites. + #[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_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); + } + + 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_tooling_report( + command: &str, + report: &ToolingReport, + json: bool, + output: &mut dyn Write, +) -> io::Result<()> { + if json { + 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() { + 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_json( + &Report { + status: "accepted", + summary: "schema structure inspected", + }, + &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 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)] + 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_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/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..04004d22c --- /dev/null +++ b/crates/registry-relayctl/src/shared.rs @@ -0,0 +1,42 @@ +// 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, + explain: args.explain, + }), + 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..1e18d6aa4 --- /dev/null +++ b/crates/registry-relayctl/tests/cli_contract.rs @@ -0,0 +1,155 @@ +// 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")) + .args(arguments) + .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 [ + "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}" + ); + } +} + +#[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/docs/site/astro.config.mjs b/docs/site/astro.config.mjs index 5eb2dea16..71556eaaf 100644 --- a/docs/site/astro.config.mjs +++ b/docs/site/astro.config.mjs @@ -368,6 +368,17 @@ export default defineConfig({ { 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/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..5761926ac 100644 --- a/docs/site/scripts/information-architecture.test.mjs +++ b/docs/site/scripts/information-architecture.test.mjs @@ -75,7 +75,7 @@ test('publishes one overview route for every task-flow section', () => { } }); -test('groups Relay tutorials under existing registries', () => { +test('keeps maintained Relay routes and adds the Relay V2 preview', () => { const start = topLevelSection(sidebarSource, 'Start'); assert.doesNotMatch( start, @@ -94,6 +94,18 @@ test('groups Relay tutorials under existing registries', () => { connect, /label: 'Connect an HTTP registry', slug: 'tutorials\/author-registry-project'/, ); + assertOrdered( + connect, + [ + "slug: 'explanation/governed-registry-publication'", + "slug: 'tutorials/publish-governed-sqlite-registry'", + "slug: 'explanation/relay-semantics-and-disclosure'", + "slug: 'configure/relay'", + "slug: 'operate/relay'", + ], + 'Relay V2 reader journey', + ); + assert.match(connect, /label: 'Relay V2 preview'/); assert.doesNotMatch(connect, /verify-opencrvs-claims/); assert.match( homepageSource, 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..19c794019 --- /dev/null +++ b/docs/site/src/content/docs/configure/relay.mdx @@ -0,0 +1,274 @@ +--- +title: Author a Registry Relay project +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: + - registry-stack +last_reviewed: "2026-08-10" +doc_type: how-to +locale: en +standards_referenced: + - openapi + - json-schema + - json-ld + - shacl + - geojson + - json-fg + - govstack-digital-registries +--- + +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 access profiles, wire formats, query capabilities, 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 Registry Authority has selected an authoritative SQLite source. +The result is a deployment candidate for institutional and operator review, not a running service. + +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 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 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 + +Initialize the project, then inspect the source copy: + +```sh +relayctl init ./business-registry +relayctl inspect ./business-inspection.sqlite --starters ./business-registry/inspection +``` + +`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. + +Copy only accepted view bindings and the schema fingerprint into `registry.yaml`. +Unconfigured tables, columns, joins, expressions, and sort order remain unavailable to callers. + +## Keep Registry Core and published properties separate + +Every successful Record always carries the Registry Core context: + +- `registryIdentifier` and `recordIdentifier`. +- `revisionIdentifier`, `lifecycleState`, and source-owned `recordedAt`. +- `authorityIdentifier`, `schemaReference`, and `semanticModelReference`. +- `domainData`, containing only serializable published properties. + +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. + +## Review classification as one governed input + +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. + +Generate the deterministic, value-free review inputs beneath `generated/`: + +```sh +relayctl generate ./business-registry +``` + +The command writes: + +- `reports/identification-report.json` +- `reports/classification-inventory.json` +- `reports/operation-explanation.json` +- `reports/contextual-review-findings.json` +- `governance/classification-review-starter.yaml` + +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 +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 access profiles + +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 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 `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`. 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 +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]} + registrar-premises: {properties: [businessRegistrationNumber, premisesName, location]} +operations: + list: + 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 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 [synthetic Relay tutorial](../../tutorials/publish-governed-sqlite-registry/) +executes the tracked business-registry 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 an access profile can +serialize. +Compiler validity, audit context, cache eligibility, and source processing account for the +processing level, even where the released response is less restrictive. + +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. + +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. + +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. + +## 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 --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. +Relay loads one complete package at startup and does not merge, reload, overlay, or fall back to a +different interpretation. + +## Troubleshooting + +| Symptom | Cause | Fix | +| --- | --- | --- | +| `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 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/explanation/governed-registry-publication.mdx b/docs/site/src/content/docs/explanation/governed-registry-publication.mdx new file mode 100644 index 000000000..86f226e82 --- /dev/null +++ b/docs/site/src/content/docs/explanation/governed-registry-publication.mdx @@ -0,0 +1,150 @@ +--- +title: How Relay publishes a governed Registry +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: + - registry-stack +last_reviewed: "2026-08-10" +doc_type: explanation +locale: en +standards_referenced: + - openapi + - geojson + - json-fg + - govstack-digital-registries + - universal-dpi-safeguards +--- + +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, access profiles, wire formats, 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 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`. + +## 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, 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 access profiles, not database columns + +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 `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 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. + +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 access profile. +The processing floor drives compiler validity, source projection, cache eligibility, and audit +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. +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 synthetic tutorial exercises the same +authorization, disclosure, audit, and cache boundary through the Relay router. + +## Offer only declared read capabilities + +Relay compiles only the operations the publisher declares: + +| Operation | Consultation capability | Result | +| --- | --- | --- | +| 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 collection search constrained by one declared CRS84 bbox. | + +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. +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 + +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, 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. + +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 + +- [Review semantics, classification, and disclosure](../relay-semantics-and-disclosure/) for the + 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 new file mode 100644 index 000000000..78a1b6258 --- /dev/null +++ b/docs/site/src/content/docs/explanation/relay-semantics-and-disclosure.mdx @@ -0,0 +1,180 @@ +--- +title: Semantics, classification, and disclosure in Relay +description: Understand how Relay separates source processing, public meaning, access profiles, and bounded disclosure. +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 + - geojson + - json-fg + - govstack-digital-registries + - universal-dpi-safeguards +--- + +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 source structure from public meaning + +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. + +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 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 +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 operation response schema validates Registry Core and the domain properties that its access +profile 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 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 access profile | The sensitivity of the releasable output. | + +The processing floor applies even when the output floor is less restrictive. +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. +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, +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`. +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 access profiles + +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 `accessProfile` selects a named access profile. +When absent, Relay uses the declared default. +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 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. + +## 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 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 +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 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 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. 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 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. +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 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 new file mode 100644 index 000000000..7a4df4428 --- /dev/null +++ b/docs/site/src/content/docs/operate/relay.mdx @@ -0,0 +1,173 @@ +--- +title: Operate Registry Relay +description: Deploy one sealed Registry package with read-only SQLite, authentication, auditing, limits, and a private listener. +status: draft +owner: registry-docs +source_repos: + - registry-stack +last_reviewed: "2026-08-10" +doc_type: how-to +locale: en +standards_referenced: + - geojson + - json-fg +--- + +Deploy one reviewed Relay package without allowing deployment configuration to change Registry +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. + +## When to use this + +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 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 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 +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 +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 +service identity. +The service fails closed on non-Unix platforms where it cannot enforce those ownership and mode +checks. + +## Bind deployment inputs without editing the package + +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"} +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/secrets/audit-integrity-key +cursor: {integrityKeyRef: secret:file/secrets/cursor-integrity-key, maximumAgeSeconds: 300} +limits: {requestTimeoutMilliseconds: 1500, concurrentQueries: 32} +quotas: {requestsPerMinute: 120, burst: 20} +``` + +`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 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, 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. + +`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. + +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 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. + +## Choose the source profile already reviewed + +The package selects the source profile. +Runtime cannot change it. + +| 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 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 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 + +Start the process using the exact runtime file: + +```sh +sudo -u /usr/local/bin/relay serve --runtime /etc/relay/business/runtime.yaml +``` + +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 +curl -fsS http://127.0.0.1:8080/health +curl -fsS http://127.0.0.1:8080/ready +``` + +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. + +## Retain value-free audit evidence + +For every data operation, including anonymous public access, Relay persists an attempt event before +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, 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 +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. + +Rollback activates a complete prior package only with its compatible source and runtime bindings. + +## Troubleshooting + +| Symptom | Cause | Fix | +| --- | --- | --- | +| 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 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 5fd59af89..9c75a37bb 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 wire-format profile + +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 new file mode 100644 index 000000000..1e3552951 --- /dev/null +++ b/docs/site/src/content/docs/tutorials/publish-governed-sqlite-registry.mdx @@ -0,0 +1,336 @@ +--- +title: Publish a governed SQLite registry +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: + - registry-stack +last_reviewed: "2026-08-10" +doc_type: tutorial +persona: + - data publisher + - operator +locale: en +standards_referenced: + - openapi + - json-schema + - json-ld + - shacl + - geojson + - json-fg +--- + +import QuickstartMeta from '../../../components/QuickstartMeta.astro'; + +Run a synthetic business Registry through Registry Relay, from a reviewed +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 +replay a request for only two permitted properties through the Relay router. + + + +## Before you start + +This preview builds Relay from the source checkout. Run every command from the +repository root unless the tutorial tells you to change directory. + +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 authoring tool + +Build `relayctl`, the project-authoring command: + +```sh +repo_root="$(pwd -P)" +cargo build --locked -p registry-relayctl +export PATH="$repo_root/target/debug:$PATH" +relayctl --version +``` + +The final line has this form. The version follows your checkout: + +```text +relayctl +``` + +`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 + +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")" +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 +``` + +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 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 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 +may release. The SQLite database remains a separate deployment input and is +not copied into the package. + +## Verify the project + +Compile the production checks before generating or serving anything: + +```sh +relayctl check "$project" --production --explain +``` + +The successful explanation is grouped by Registry resource and operation. Its exact digests follow +your checkout, but the shape includes these sections: + +```text +relayctl check +Registry: urn:example:registry:registered-businesses +Contract revision: sha256: + +Resource: registered-premises + + 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) + 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. 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: + +```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 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, 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. 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 + +Package the reviewed contract and generated artifacts: + +```sh +relayctl package "$project" --output "$project/package" +``` + +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. It starts from this complete package only and +does not reload, merge, or fall back to a different interpretation. + +## Verify the minimized request boundary + +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 +relayctl test "$project" --fixture filtered-page +``` + +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. + +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 + +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 first runs `premises-first-page`, the declared record-equivalence prerequisite, then the +selected JSON-FG step. Both pass. The selected step is: + +```json +{ + "id": "premises-feature-collection-jsonfg", + "expectedStatus": 200, + "actualStatus": 200, + "passed": true +} +``` + +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 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. + +## Remove the disposable project + +Return to the repository root and remove the tutorial copy: + +```sh +cd "$repo_root" +rm -rf -- "$reader_root" +``` + +The cleanup commands print nothing when they succeed. + +## What you built + +- 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 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 + 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 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, + 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 --explain` again. | diff --git a/docs/site/src/data/generated/standards.json b/docs/site/src/data/generated/standards.json index c1f77ee42..677693e0f 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 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 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", + "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 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 wire format. 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..9b8c715d4 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 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 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 + 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 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 wire format. It does not implement extended JSON-FG geometries or a generic feature API. - id: openapi name: OpenAPI standards_body: OpenAPI Initiative 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..b0a3fd110 --- /dev/null +++ b/products/relay-v2/CONCEPT.md @@ -0,0 +1,844 @@ +# Relay V2 Product Concept + +Status: Approved product direction +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 +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, 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 + +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, 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; +- 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 --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. + +## 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, 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. + +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. 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, +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. + +### 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 +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/operation-explanation.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 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 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 +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, 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`, `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 +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. + +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 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: + +- 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. An operation has a finite ordered +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 +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 +scope. + +### 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=...&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} +``` + +`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 +client-opaque authenticated-encrypted cursor binds the contract and source +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 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 +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 +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 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 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 +`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 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 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 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. `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`. `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. + +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 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 +to the generated local vocabulary/model, not merely the JSON-LD context. The +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. + +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, 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, 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. + +### Derived 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. +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 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, +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 + +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, +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. + +`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 + +Relay supports two explicit SQLite profiles: + +- 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. 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: +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, 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, 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 +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, 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-*` + +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 sealed-package verification and activation; +- 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. 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; +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, richer spatial data, 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, 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 + 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. 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 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. +- 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 + +- 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; +- formal GovStack conformance or a compatibility flag translating wire conventions; +- a Digital Registries family beyond Consultation; +- 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 new file mode 100644 index 000000000..41a529840 --- /dev/null +++ b/products/relay-v2/CONFIGURATION-EXAMPLES.md @@ -0,0 +1,1100 @@ +# Relay V2 Configuration Examples + +Status: Illustrative design probes +Date: 2026-08-10 +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 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; +- every Record has mandatory Registry Core bindings in addition to selectable domain properties; +- `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 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. + +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 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. + +## 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: + enrolment_reference: {privacy: identifying, institutional: restricted, handling: restricted, status: reviewed} + 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} + 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 + 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: + limited: + properties: [maskedEnrolmentReference, enrolmentStatus, validThrough] + caseworker: + properties: [enrolmentReference, programmeCode, enrolmentStatus, entitlementCategory, validThrough, serviceOfficeCode] + + operations: + lookups: + - id: by-case-and-person + requestBody: + maximumBytes: 512 + selectors: + caseReference: {sourceColumn: case_reference, type: string, minimumBytes: 8, maximumBytes: 96} + personReference: {sourceColumn: person_reference, type: string, minimumBytes: 8, maximumBytes: 96} + defaultAccessProfile: limited + accessProfiles: + 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"] + 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, 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 + +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: 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 + 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] + registrar-register: + properties: [registrationNumber, registrarLegalName, registrationStatus, legalForm, registeredJurisdiction] + + operations: + list: + defaultAccessProfile: public-register + accessProfiles: + 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} + allowUnfiltered: true + orderBy: [registrationNumber] + pagination: {defaultPageSize: 50, maximumPageSize: 200} + read: + defaultAccessProfile: public-register + accessProfiles: + public-register: {access: public, disclosureProfile: public-register} + registrar: {access: {scope: registry:business:read-registrar}, disclosureProfile: registrar-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`, 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. + +### 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 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 + 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, location]} + registrar-premises: {properties: [premisesIdentifier, businessRegistrationNumber, location]} + operations: + list: + 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: + 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` 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 +`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 +`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. + +## 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} + 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 + 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, registrationDate, certificateAvailable] + supervisory-verification: + properties: [eventReference, eventType, registrationStatus, registrationYear, certificateAvailable] + + operations: + read: + defaultAccessProfile: registrar + accessProfiles: + 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 + 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} + defaultAccessProfile: registrar-verification + accessProfiles: + 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] + 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 --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 +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.accessProfiles +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 +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[].accessProfiles +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 +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.accessProfiles +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.* +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 +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 +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 +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.*.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 +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 operation query shape + -> 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 +``` + +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, 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 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; +- 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..efa3a9851 --- /dev/null +++ b/products/relay-v2/DEFINITION-OF-DONE.md @@ -0,0 +1,207 @@ +# Relay V2 Definition of Done + +Status: Approved acceptance contract +Date: 2026-08-10 +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, GeoPackage decoding, generic geometry, 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, 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, +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, 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 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, 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, 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, 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, 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. | +| 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 + +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 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, 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 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. + +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; +- 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. + +### 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 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 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 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. + +### Civil-event registry cases + +- protected identifier read and named exact verification lookup, with collection listing absent; +- 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; +- 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 an access-profile 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 + +- 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 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, expected result, and exact executable negative-test traceability. + +## 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..951c38896 --- /dev/null +++ b/products/relay-v2/IMPLEMENTATION.md @@ -0,0 +1,808 @@ +# Relay V2 Implementation Plan + +Status: Approved implementation plan +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) + +## 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, 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. | + +`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, 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 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 + 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 +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. 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, +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 +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/... +compiled/registry.json +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 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 +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. 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. + +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 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 +limits remain Relay-owned and never enter Manifest. + +### Identification, review, and governed access-profile increment + +The compiler owns the closed access-profile model and never receives +caller-authored transforms or policy expressions. It validates exactly one +`defaultAccessProfile` against each finite operation map; compiles access, +disclosure, processing handling, disclosure handling, transform inventory, and +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 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 +`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. + +`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 access profile, +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 +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` 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. + +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", + "accessProfile": "public-register", + "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`. 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 +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 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. + +`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` 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`. + +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 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 +`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 + +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=...&=...&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} +``` + +`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`; 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 +`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`, +`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. 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 +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 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. + +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 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. + +Compiled operations derive their capability mapping: + +- read: `consultation.retrieve`; +- list: `consultation.list`; +- 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 +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, 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 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 +`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` | +| 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 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 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` 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` | +| 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, 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` | + +`/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 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 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-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. + +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. 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 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. + +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 --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. +- 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/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 + 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, 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. + +### 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 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, + 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, + 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..518754e75 --- /dev/null +++ b/products/relay-v2/README.md @@ -0,0 +1,54 @@ +# 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; +- 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; +- 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/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, +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 new file mode 100644 index 000000000..e3518be72 --- /dev/null +++ b/products/relay-v2/STANDARDS-ALIGNMENT.md @@ -0,0 +1,66 @@ +# 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 | 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 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`. | +| 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. +- 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 + 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 + 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..56f122238 --- /dev/null +++ b/products/relay-v2/acceptance/business-registry/expected-http.yaml @@ -0,0 +1,415 @@ +schemaVersion: relay.registrystack.org/http-journey/v1alpha1 +registry: urn:example:registry:registered-businesses +authorizations: + business-registrar: + principal: synthetic-business-registrar + scopes: [registry:business:read-registrar] + claims: {} + business-unentitled: + 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} + expect: + status: 200 + capabilityPatterns: [consultation.list, consultation.retrieve, consultation.search] + absentCapabilityPatterns: [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: registrar-read + authorizationFixture: business-registrar + request: + method: GET + path: /v2/resources/registered-business/records/BIZ-SYNTH-0001 + query: {accessProfile: registrar, fields: "registrarLegalName,registrarNote"} + expect: + status: 200 + registryCoreRequired: true + domainDataKeys: [registrarLegalName, registrarNote] + cache: no-store + - id: registrar-access-profile-denied + authorizationFixture: business-unentitled + request: + method: GET + path: /v2/resources/registered-business/records/BIZ-SYNTH-0001 + query: {accessProfile: registrar} + expect: {status: 404, code: resource.not_found} + - id: public-access-profile-unknown + request: + method: GET + path: /v2/resources/registered-business/records/BIZ-SYNTH-0001 + query: {accessProfile: registrar-private} + expect: {status: 404, code: resource.not_found} + - 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: premises-first-page + request: + method: GET + 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, 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 + itemCount: 1 + nextCursor: "null" + registryCoreRequired: true + - id: premises-boundary-point + request: + method: GET + 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/searches/within-bbox + 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, 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, premisesName, location] + recordsEquivalentTo: premises-read + - id: premises-feature-collection + request: + method: GET + path: /v2/resources/registered-premises/searches/within-bbox + headers: {accept: application/geo+json} + query: {bbox: "100,13,101,14", formatProfile: rfc7946} + expect: + status: 200 + itemCount: 2 + nextCursor: non-null + registryCoreRequired: true + domainDataKeys: [premisesIdentifier, premisesName] + geoJsonRoot: feature-collection + geometryType: Point + 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/searches/within-bbox + headers: {accept: application/geo+json} + query: {bbox: "100,13,101,14", formatProfile: jsonfg} + expect: + status: 200 + itemCount: 2 + nextCursor: non-null + registryCoreRequired: true + domainDataKeys: [premisesIdentifier, premisesName] + geoJsonRoot: feature-collection + geometryType: Point + 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: {formatProfile: rfc7946} + expect: + status: 200 + recordIdentifier: PREM-SYNTH-0001 + registryCoreRequired: true + domainDataKeys: [premisesIdentifier, premisesName] + geoJsonRoot: feature + geometryType: Point + formatProfile: rfc7946 + recordsEquivalentTo: premises-read + - id: premises-feature-fields-omit-location + request: + method: GET + path: /v2/resources/registered-premises/searches/within-bbox + headers: {accept: application/geo+json} + query: {bbox: "100,13,101,14", pageSize: 4, fields: "premisesIdentifier,premisesName", formatProfile: rfc7946} + expect: + status: 200 + itemCount: 3 + nextCursor: "null" + registryCoreRequired: true + domainDataKeys: [premisesIdentifier, premisesName] + geoJsonRoot: feature-collection + geometryType: "null" + formatProfile: rfc7946 + recordsEquivalentTo: premises-fields-omit-location + - id: premises-invalid-bbox + request: + method: GET + 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/searches/within-bbox + 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/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/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/searches/within-bbox + query: {cursor: "$nextCursor:premises-first-page", bbox: "100,13,101,14"} + expect: {status: 400, code: query.cursor_invalid} + - id: premises-cursor-format-binding + request: + method: GET + 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: format.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 + 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: 503, code: source.unavailable} 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..b37c31da2 --- /dev/null +++ b/products/relay-v2/acceptance/business-registry/fixture.sql @@ -0,0 +1,63 @@ +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, + 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', '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, + public_legal_name, + legal_name AS registrar_legal_name, + registrar_note, + registration_status, + 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-rationale.md b/products/relay-v2/acceptance/business-registry/governance/classification-review-rationale.md new file mode 100644 index 000000000..0607b1801 --- /dev/null +++ b/products/relay-v2/acceptance/business-registry/governance/classification-review-rationale.md @@ -0,0 +1,5 @@ +# Classification review rationale + +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 new file mode 100644 index 000000000..610884d54 --- /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:efa130457a49b6cde79bdbc8ca7b10eb3b7d5dc77a8ca0f1e216e3eb89ed189e +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/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..963c804fd --- /dev/null +++ b/products/relay-v2/acceptance/business-registry/registry.yaml @@ -0,0 +1,249 @@ +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:6a46a9be0a3d5b4a5650934c7e8ef73ad1803cb479981f7d235a1c17a335af52 + 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/classification-review.yaml +sources: + companies: + kind: sqlite + profile: snapshot + expectedSchemaFingerprint: sha256:dd62b98578f0fa7341eeeaaac4b34da9b79405ae067dc06e5edb004c2d4a38fe +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} + 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 + type: string + sourceRequired: true + semanticTerm: local:registrationNumber + label: Registration number + description: Stable synthetic business registration number. + legalName: + 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 + 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] + registrar-register: + properties: [registrationNumber, registrarLegalName, registrarNote, registrationStatus, legalForm, registeredJurisdiction] + operations: + list: + defaultAccessProfile: public-register + accessProfiles: + 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} + allowUnfiltered: true + orderBy: [registrationNumber] + pagination: {defaultPageSize: 2, maximumPageSize: 4} + read: + defaultAccessProfile: public-register + accessProfiles: + public-register: {access: public, disclosureProfile: public-register} + registrar: + access: {scope: registry:business:read-registrar} + disclosureProfile: registrar-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] + - 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, premisesName, location] + registrar-premises: + properties: [premisesIdentifier, businessRegistrationNumber, premisesName, location] + operations: + list: + 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: + 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, search:within-bbox] + 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 + 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..f318879a7 --- /dev/null +++ b/products/relay-v2/acceptance/business-registry/runtime.yaml @@ -0,0 +1,29 @@ +apiVersion: relay.registrystack.org/v2alpha1 +kind: RelayRuntime +server: + bind: 127.0.0.1:18082 +packagePath: package +sources: + companies: + path: fixture.sqlite +authentication: + 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 +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..631d86134 --- /dev/null +++ b/products/relay-v2/acceptance/business-registry/semantics/local-vocabulary.yaml @@ -0,0 +1,17 @@ +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.} + - {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 new file mode 100644 index 000000000..cab168da5 --- /dev/null +++ b/products/relay-v2/acceptance/business-registry/semantics/semic-business-alignment.yaml @@ -0,0 +1,10 @@ +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} + - {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/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/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..e9c4a6d0f --- /dev/null +++ b/products/relay-v2/acceptance/civil-event/expected-http.yaml @@ -0,0 +1,211 @@ +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-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] + 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,registrationDate,certificateAvailable"} + body: {registrationNumber: REG-SYNTH-000001, eventType: BIRTH} + expect: + status: 200 + registryCoreRequired: true + 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: {accessProfile: supervisory, fields: "eventType,registrationYear,registrationYearMonth"} + body: {registrationNumber: REG-SYNTH-000001, eventType: BIRTH} + expect: + status: 200 + registryCoreRequired: true + domainDataKeys: [eventType, registrationYear, registrationYearMonth] + domainDataValues: {registrationYear: "2026", registrationYearMonth: "2026-04"} + cache: no-store + - id: supervisory-access-profile-denied + authorizationFixture: civil-verifier-ex-a + request: + method: POST + path: /v2/resources/civil-event/lookups/verify-registration + query: {accessProfile: supervisory} + body: {registrationNumber: REG-SYNTH-000001, eventType: BIRTH} + expect: {status: 404, code: resource.not_found} + - id: invalid-access-profile + authorizationFixture: civil-verifier-ex-a + request: + method: POST + path: /v2/resources/civil-event/lookups/verify-registration + query: {accessProfile: invalid} + body: {registrationNumber: REG-SYNTH-000001, eventType: BIRTH} + expect: {status: 404, code: resource.not_found} + - 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: 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: 404, code: resource.not_found} + - 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: 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: {accessProfile: supervisory} + body: {registrationNumber: REG-SYNTH-XFORM1, eventType: BIRTH} + expect: {status: 503, code: source.unavailable} + - 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..0df9fc940 --- /dev/null +++ b/products/relay-v2/acceptance/civil-event/fixture.sql @@ -0,0 +1,37 @@ +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'), +('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, + 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/classification-review-rationale.md b/products/relay-v2/acceptance/civil-event/governance/classification-review-rationale.md new file mode 100644 index 000000000..e062979ee --- /dev/null +++ b/products/relay-v2/acceptance/civil-event/governance/classification-review-rationale.md @@ -0,0 +1,5 @@ +# Classification review rationale + +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 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 new file mode 100644 index 000000000..15ae51158 --- /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:2ddf244cfa195c322070d0154cf66a48618e8d5ea76cf931a9ad2c8c4682fc42 +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/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..7db92d90a --- /dev/null +++ b/products/relay-v2/acceptance/civil-event/registry.yaml @@ -0,0 +1,191 @@ +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/classification-review.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} + registration_date: {privacy: personal, 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} + 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. + 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} + 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 + 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, registrationDate, certificateAvailable] + supervisory-verification: + properties: [eventReference, eventType, registrationStatus, registrationYear, registrationYearMonth, certificateAvailable] + operations: + read: + defaultAccessProfile: registrar + accessProfiles: + 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 + 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-selector-types.yaml} + defaultAccessProfile: registrar-verification + accessProfiles: + 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] + 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..e904592a0 --- /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: 8 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..0706b74fe --- /dev/null +++ b/products/relay-v2/acceptance/civil-event/semantics/local-vocabulary.yaml @@ -0,0 +1,15 @@ +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: 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/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..40479d055 --- /dev/null +++ b/products/relay-v2/acceptance/social-assistance/expected-http.yaml @@ -0,0 +1,227 @@ +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: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:limited] + claims: {purpose: unpermitted-purpose, service_area: AREA-A} + social-lookup-missing-purpose: + principal: synthetic-social-client + scopes: [registry:social-assistance:limited] + claims: {service_area: AREA-A} + social-lookup-missing-binding: + principal: synthetic-social-client + scopes: [registry:social-assistance:limited] + claims: {purpose: benefit-delivery} + social-lookup-wrong-binding: + principal: synthetic-social-client + scopes: [registry:social-assistance:limited] + 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: [maskedEnrolmentReference, enrolmentStatus, validThrough] + domainDataValues: {maskedEnrolmentReference: "***0001"} + - id: caseworker-access-profile + authorizationFixture: social-caseworker-area-a + request: + method: POST + path: /v2/resources/assistance-enrolment/lookups/by-case-and-person + 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-access-profile + authorizationFixture: social-caseworker-wrong-scope + request: + method: POST + path: /v2/resources/assistance-enrolment/lookups/by-case-and-person + query: {accessProfile: caseworker} + body: {caseReference: CASE-SYNTH-0001, personReference: PERSON-SYNTH-0001} + expect: {status: 404, code: resource.not_found} + - id: unknown-access-profile + authorizationFixture: social-lookup-area-a + request: + method: POST + path: /v2/resources/assistance-enrolment/lookups/by-case-and-person + query: {accessProfile: unknown-profile} + body: {caseReference: CASE-SYNTH-0001, personReference: PERSON-SYNTH-0001} + expect: {status: 404, code: resource.not_found} + - id: duplicate-access-profile + authorizationFixture: social-lookup-area-a + request: + method: POST + path: /v2/resources/assistance-enrolment/lookups/by-case-and-person + query: {accessProfile: "limited,caseworker"} + body: {caseReference: CASE-SYNTH-0001, personReference: PERSON-SYNTH-0001} + expect: {status: 400, code: request.access_profile_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: maskedEnrolmentReference} + body: {caseReference: CASE-SYNTH-0001, personReference: PERSON-SYNTH-0001} + expect: {status: 200, registryCoreRequired: true, domainDataKeys: [maskedEnrolmentReference]} + - 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: 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: 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: + 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..e4e820e11 --- /dev/null +++ b/products/relay-v2/acceptance/social-assistance/fixture.sql @@ -0,0 +1,38 @@ +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, + 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', '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, + 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/classification-review-rationale.md b/products/relay-v2/acceptance/social-assistance/governance/classification-review-rationale.md new file mode 100644 index 000000000..3bcfa5b8e --- /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 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 new file mode 100644 index 000000000..d7eb7dd3a --- /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:01b68daa7f9ed5f92d95dea53957b6970f8ce527ccfa1d43f777a9573a0e6294 +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/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..288980102 --- /dev/null +++ b/products/relay-v2/acceptance/social-assistance/registry.yaml @@ -0,0 +1,145 @@ +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/classification-review.yaml +sources: + assistance: + kind: sqlite + profile: live-read-only + expectedSchemaFingerprint: sha256:936a90a03d06be67a76226d6999a830c04f6604a3ff8b340a62fdd378d8c6d91 +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: + 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} + 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} + 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 + 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: + limited: + properties: [maskedEnrolmentReference, enrolmentStatus, validThrough] + caseworker: + properties: [enrolmentReference, programmeCode, enrolmentStatus, validThrough] + operations: + lookups: + - id: by-case-and-person + requestBody: + maximumBytes: 512 + selectors: + caseReference: {sourceColumn: case_reference, type: string, minimumBytes: 8, maximumBytes: 96} + personReference: {sourceColumn: person_reference, type: string, minimumBytes: 8, maximumBytes: 96} + defaultAccessProfile: limited + accessProfiles: + 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] + 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/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 new file mode 100644 index 000000000..6f39bcd2e --- /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: 12 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..e5d1f5799 --- /dev/null +++ b/products/relay-v2/contracts/acceptance-scenario-matrix.yaml @@ -0,0 +1,103 @@ +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 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.} + - {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, 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.} + - {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-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.} + - {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, 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 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 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-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 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 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.} + - {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, 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/artifact-inventory.yaml b/products/relay-v2/contracts/artifact-inventory.yaml new file mode 100644 index 000000000..038cde1ca --- /dev/null +++ b/products/relay-v2/contracts/artifact-inventory.yaml @@ -0,0 +1,105 @@ +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: access-profile-schema + mediaType: application/schema+json + visibility: operation-compatible + source: compiled-resource + generated: true + 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-access-profile + generated: true + 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-access-profile + generated: true + 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 Record 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 + 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 + 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: operation-explanation + mediaType: application/json + visibility: operator-only + source: compiled-operations + generated: true + 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 + 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 new file mode 100644 index 000000000..8e0a817c6 --- /dev/null +++ b/products/relay-v2/contracts/generated-baselines.yaml @@ -0,0 +1,1226 @@ +schemaVersion: relay.registrystack.org/generated-baselines/v1alpha1 +product: relay-v2 +projects: + social-assistance: + packageRevision: sha256:e54b43d7f59419c3231659a5a802ab95af856c435379797cfd9aa0b9fd42b577 + contractRevision: sha256:9011885e752b26128bf6c98798e5fd674624ae04912cd37c7597311e8a805b1e + sourceSchemaFingerprints: + assistance: sha256:936a90a03d06be67a76226d6999a830c04f6604a3ff8b340a62fdd378d8c6d91 + artifacts: + - 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--access-profile-caseworker.capability.json + sha256: sha256:4d83f1180e557ebff56e599fe11684d4a801add1e2a0a82b68ef136216cf7202 + visibility: operation-bound + - 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--access-profile-caseworker.classifications.json + sha256: sha256:57a918902073fdd17ca48974e6e7d153eed11a9f33409b39e193f6c5030f53f8 + visibility: operator-only + - 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--access-profile-caseworker.context.jsonld + sha256: sha256:220f8ac8890bd1167e90c4aa836d75858d89cc0016173227ac7aafaf6a8e9b07 + visibility: operation-bound + - 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--access-profile-caseworker.processing.json + sha256: sha256:af17652b596290134bb38c594b14f7dac2b9caa6b9bef1119511b2f399053e3e + visibility: operation-bound + - 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--access-profile-caseworker.schema.json + sha256: sha256:447d9fcb5e36478b1b809d55489c07157deaf033ca15d3363163bccd993fdf5f + visibility: operation-bound + - 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--access-profile-caseworker.shacl.ttl + sha256: sha256:68664354ffccbe112d1a7e06dd68d96d5cd66bc681bc8e44495875b46696c076 + visibility: operation-bound + - 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--access-profile-caseworker.vocabulary.jsonld + sha256: sha256:d894822eb794725509df894466e19f0e96caa99e8a612a358cb1dc25b71a1a86 + visibility: operation-bound + - 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--access-profile-limited.capability.json + sha256: sha256:7e985d90283cdd6a0e514f87cd1197ea8d74d31620092528272063dc05d1883e + visibility: operation-bound + - 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--access-profile-limited.classifications.json + sha256: sha256:cab6d216d1ce7e68ff149eb73971ad317977dbd1108c2b4b7e00e00b1f5db4de + visibility: operator-only + - 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--access-profile-limited.context.jsonld + sha256: sha256:58aec134af96c8b39f9f0a8dde7c5dd780fedc419637fe1848724a4120d54fc9 + visibility: operation-bound + - 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--access-profile-limited.processing.json + sha256: sha256:3b16cd7620010e103eb2da975a9036d89ffc0d68ffc23b26374a4b540e17bdd0 + visibility: operation-bound + - 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--access-profile-limited.schema.json + sha256: sha256:36b2a2583c668b50241a5ec9ee787126bb83d53a7517f9c11e9aa1286e27c7d5 + visibility: operation-bound + - 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--access-profile-limited.shacl.ttl + sha256: sha256:ea795cccdde860699ed7998e38cdae8f4dd1784acda936242a82cb62b796f95f + visibility: operation-bound + - 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--access-profile-limited.vocabulary.jsonld + sha256: sha256:d856a45b101cce510ad7ff1d1773f0326e69cd4b52d5ea00fa6534f74d79a881 + visibility: operation-bound + - accessProfileIdentifier: null + id: assistance-enrolment-classification + mediaType: application/json + operationIdentifier: null + path: generated/artifacts/assistance-enrolment.classifications.json + sha256: sha256:de4a4b21602b10f8b4cbdcc06d9d01f05c552259656c755accad700d4dbcb086 + visibility: operator-only + - accessProfileIdentifier: null + 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 + - accessProfileIdentifier: null + 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 + - accessProfileIdentifier: null + 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 + - accessProfileIdentifier: null + id: assistance-enrolment-full-schema + mediaType: application/schema+json + operationIdentifier: null + path: generated/artifacts/assistance-enrolment.full.schema.json + sha256: sha256:4c669711db0b989ac58e1a36ec52afedd05fe2726f7439c0f204fcb0050bb79d + visibility: operator-only + - accessProfileIdentifier: null + id: assistance-enrolment-full-shacl + mediaType: text/turtle + operationIdentifier: null + path: generated/artifacts/assistance-enrolment.full.shacl.ttl + sha256: sha256:53324cf42d1b66d8292897b7d046fe7a68f2c99802c3df7963bb17506ed9e1ad + visibility: operator-only + - accessProfileIdentifier: null + id: assistance-enrolment-full-vocabulary + mediaType: application/ld+json + operationIdentifier: null + path: generated/artifacts/assistance-enrolment.full.vocabulary.jsonld + sha256: sha256:ce35a8374c9a8758f7eb42ed86f77503f2f371ba3da16d17eaa2df1d7a320f92 + visibility: operator-only + - accessProfileIdentifier: null + id: assistance-enrolment-processing-full + mediaType: application/json + operationIdentifier: null + path: generated/artifacts/assistance-enrolment.processing.full.json + sha256: sha256:b3806fac8892ef081c3d8e26ca475fb37c3d318302f593b25828c20110a1f7b5 + visibility: operator-only + - accessProfileIdentifier: null + id: audit-event-schema + mediaType: application/schema+json + operationIdentifier: null + path: generated/artifacts/audit-event.schema.json + sha256: sha256:2600120dbc7fbbb0f8d4feaa7cb811055b6f2590ad83c4472d5af982ae004a45 + visibility: operator-only + - accessProfileIdentifier: null + id: capability-inventory-full + mediaType: application/json + operationIdentifier: null + path: generated/artifacts/capabilities.full.json + sha256: sha256:0789a41100832281b8b357922fe76a0732620fdf8befaffdc3e4d1374ff7b12a + visibility: operator-only + - accessProfileIdentifier: null + id: capability-inventory + mediaType: application/json + operationIdentifier: null + path: generated/artifacts/capabilities.json + sha256: sha256:b3862333658a891d5922836fcb080e8840f944c613c087f503bb726408eb4d05 + visibility: public + - accessProfileIdentifier: null + id: openapi-full + mediaType: application/yaml + operationIdentifier: null + path: generated/openapi.full.yaml + sha256: sha256:ed7f7507c8af5f8364addbf85a27b2e0b27376e03fbf2f750a7130d814f89247 + visibility: operator-only + - accessProfileIdentifier: null + 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/classification-review-rationale.md + sha256: sha256:377253745d4f0f85e1bbcb25ec470c93efafdf320ae01805bac798e78a8830f1 + size: 263 + visibility: operator-only + - generated: false + mediaType: application/yaml + path: governed/governance/classification-review.yaml + sha256: sha256:7879a4065f12d81278d84fa5f18a9b33f87ae64101f3f920f34424f699a9c829 + size: 763 + 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/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:621a5951fd86471650e5b3e05da83c2686bb525fbe584d3fbf7b7193d3c651a7 + size: 6470 + visibility: operator-only + business-registry: + packageRevision: sha256:fcde30f79c747796e500aa5ded84705c8d6083fa33d1cfc75542a7c6469fcfea + contractRevision: sha256:f72669730175ad097512fa9eda378bbbd3bbb64a859615e42d4752b277630968 + sourceSchemaFingerprints: + companies: sha256:dd62b98578f0fa7341eeeaaac4b34da9b79405ae067dc06e5edb004c2d4a38fe + artifacts: + - accessProfileIdentifier: null + id: audit-event-schema + mediaType: application/schema+json + operationIdentifier: null + path: generated/artifacts/audit-event.schema.json + sha256: sha256:2600120dbc7fbbb0f8d4feaa7cb811055b6f2590ad83c4472d5af982ae004a45 + visibility: operator-only + - accessProfileIdentifier: null + id: capability-inventory-full + mediaType: application/json + operationIdentifier: null + path: generated/artifacts/capabilities.full.json + sha256: sha256:77ae7574056e5687a0fc4ecfa653dd517c3d426e4e6b1d2ece59e475d91c8c41 + visibility: operator-only + - accessProfileIdentifier: null + id: capability-inventory + mediaType: application/json + operationIdentifier: null + path: generated/artifacts/capabilities.json + sha256: sha256:26bcbe794fe72b9fdece5da37361c82ed152405d003765d3acdafc3f6214a023 + visibility: public + - accessProfileIdentifier: null + id: registered-business--list--access-profile-public-register-classifications + mediaType: application/json + operationIdentifier: null + path: generated/artifacts/registered-business--list--access-profile-public-register.classifications.json + sha256: sha256:a242a993d24505958b52108869dfce092ec302eb025443a4df0d648cdf67911c + visibility: public + - accessProfileIdentifier: null + id: registered-business--list--access-profile-public-register-context + mediaType: application/ld+json + operationIdentifier: null + path: generated/artifacts/registered-business--list--access-profile-public-register.context.jsonld + sha256: sha256:a484835aa45107953b758934d5b9d13e47fc8d7c7a06c11fe709ada7e740f2d2 + visibility: public + - accessProfileIdentifier: null + id: registered-business--list--access-profile-public-register-processing + mediaType: application/json + operationIdentifier: null + path: generated/artifacts/registered-business--list--access-profile-public-register.processing.json + sha256: sha256:5f87f5571e1ac60546c0e5da43d0e7396b2a65eafa33782b2ead0e221cd5b333 + visibility: public + - accessProfileIdentifier: null + id: registered-business--list--access-profile-public-register-schema + mediaType: application/schema+json + operationIdentifier: null + path: generated/artifacts/registered-business--list--access-profile-public-register.schema.json + sha256: sha256:afb5123323248abcf329b5320cf6a3cb51f4c007b929e1c141c4287cdad91da2 + visibility: public + - accessProfileIdentifier: null + id: registered-business--list--access-profile-public-register-shacl + mediaType: text/turtle + operationIdentifier: null + path: generated/artifacts/registered-business--list--access-profile-public-register.shacl.ttl + sha256: sha256:61ac61a72c888c6c1850c16ed11b6ec6be0bb96d8f592fc417759f38a1eaaee0 + visibility: public + - accessProfileIdentifier: null + id: registered-business--list--access-profile-public-register-vocabulary + mediaType: application/ld+json + operationIdentifier: null + path: generated/artifacts/registered-business--list--access-profile-public-register.vocabulary.jsonld + sha256: sha256:24bcf44aa7b04353a8a23b2d80e5c4fe1cf6a60f0b03d0f0a0c48611631ee5d7 + visibility: public + - accessProfileIdentifier: registrar + id: registered-business--list--access-profile-registrar-capability + mediaType: application/json + operationIdentifier: registered-business.list + path: generated/artifacts/registered-business--list--access-profile-registrar.capability.json + sha256: sha256:ddeffc033daed72ebc796072eac864339a3ec850edc96a816eabaa000643c730 + visibility: operation-bound + - accessProfileIdentifier: registrar + id: registered-business--list--access-profile-registrar-classifications + mediaType: application/json + operationIdentifier: registered-business.list + path: generated/artifacts/registered-business--list--access-profile-registrar.classifications.json + sha256: sha256:f4e8deb74f2d80d3f5a5ed946112a8e93055dfc9752c8a90b55d3f882ef49ec9 + visibility: operation-bound + - accessProfileIdentifier: registrar + id: registered-business--list--access-profile-registrar-context + mediaType: application/ld+json + operationIdentifier: registered-business.list + path: generated/artifacts/registered-business--list--access-profile-registrar.context.jsonld + sha256: sha256:d9c017c057c7228e8145e149494961b7bd8edd4e46c882903af19fbb29d5c960 + visibility: operation-bound + - accessProfileIdentifier: registrar + id: registered-business--list--access-profile-registrar-processing + mediaType: application/json + operationIdentifier: registered-business.list + path: generated/artifacts/registered-business--list--access-profile-registrar.processing.json + sha256: sha256:2df97861c32c42672e87d2945d47871b736dfe97b6618243f3a1e1bb357166e5 + visibility: operation-bound + - accessProfileIdentifier: registrar + id: registered-business--list--access-profile-registrar-schema + mediaType: application/schema+json + operationIdentifier: registered-business.list + path: generated/artifacts/registered-business--list--access-profile-registrar.schema.json + sha256: sha256:4c13f868cccf249235c9a69fabc81313fd0f3e84e0a01e0d4f5cd72059498f99 + visibility: operation-bound + - accessProfileIdentifier: registrar + id: registered-business--list--access-profile-registrar-shacl + mediaType: text/turtle + operationIdentifier: registered-business.list + path: generated/artifacts/registered-business--list--access-profile-registrar.shacl.ttl + sha256: sha256:c72599e5a94a0c7a8460b3551ef10506cf9e8fa3bd906c828c4a62092ac54581 + visibility: operation-bound + - accessProfileIdentifier: registrar + id: registered-business--list--access-profile-registrar-vocabulary + mediaType: application/ld+json + operationIdentifier: registered-business.list + path: generated/artifacts/registered-business--list--access-profile-registrar.vocabulary.jsonld + sha256: sha256:1a1d5fec8194398211a8d8ea6618cef48b291b8d42814d94c5cba9af82d84b36 + visibility: operation-bound + - accessProfileIdentifier: null + id: registered-business--read--access-profile-public-register-classifications + mediaType: application/json + operationIdentifier: null + path: generated/artifacts/registered-business--read--access-profile-public-register.classifications.json + sha256: sha256:e628697ef0efdac1f00119132f9d592ec29fa328d6a56cece6fb90c80b2a6c4b + visibility: public + - accessProfileIdentifier: null + id: registered-business--read--access-profile-public-register-context + mediaType: application/ld+json + operationIdentifier: null + path: generated/artifacts/registered-business--read--access-profile-public-register.context.jsonld + sha256: sha256:a484835aa45107953b758934d5b9d13e47fc8d7c7a06c11fe709ada7e740f2d2 + visibility: public + - accessProfileIdentifier: null + id: registered-business--read--access-profile-public-register-processing + mediaType: application/json + operationIdentifier: null + path: generated/artifacts/registered-business--read--access-profile-public-register.processing.json + sha256: sha256:c438483b841471785c8393a20648facca8cc970114f894faaf3eca78eac03c6c + visibility: public + - accessProfileIdentifier: null + id: registered-business--read--access-profile-public-register-schema + mediaType: application/schema+json + operationIdentifier: null + path: generated/artifacts/registered-business--read--access-profile-public-register.schema.json + sha256: sha256:c591f600996b38d0af3c81e77e5eb72805ab70ed10e464fada3646a768bfd785 + visibility: public + - accessProfileIdentifier: null + id: registered-business--read--access-profile-public-register-shacl + mediaType: text/turtle + operationIdentifier: null + path: generated/artifacts/registered-business--read--access-profile-public-register.shacl.ttl + sha256: sha256:61ac61a72c888c6c1850c16ed11b6ec6be0bb96d8f592fc417759f38a1eaaee0 + visibility: public + - accessProfileIdentifier: null + id: registered-business--read--access-profile-public-register-vocabulary + mediaType: application/ld+json + operationIdentifier: null + path: generated/artifacts/registered-business--read--access-profile-public-register.vocabulary.jsonld + sha256: sha256:24bcf44aa7b04353a8a23b2d80e5c4fe1cf6a60f0b03d0f0a0c48611631ee5d7 + visibility: public + - accessProfileIdentifier: registrar + id: registered-business--read--access-profile-registrar-capability + mediaType: application/json + operationIdentifier: registered-business.read + path: generated/artifacts/registered-business--read--access-profile-registrar.capability.json + sha256: sha256:be3cfc06111851257df4e27ae9f2b17ebb193ac8de2533ad24c0f0c092bd84de + visibility: operation-bound + - accessProfileIdentifier: registrar + id: registered-business--read--access-profile-registrar-classifications + mediaType: application/json + operationIdentifier: registered-business.read + path: generated/artifacts/registered-business--read--access-profile-registrar.classifications.json + sha256: sha256:a447eae6a497e23ab720dfd67767a9665b55e231157a45766460452aeb6a9ff0 + visibility: operation-bound + - accessProfileIdentifier: registrar + id: registered-business--read--access-profile-registrar-context + mediaType: application/ld+json + operationIdentifier: registered-business.read + path: generated/artifacts/registered-business--read--access-profile-registrar.context.jsonld + sha256: sha256:d9c017c057c7228e8145e149494961b7bd8edd4e46c882903af19fbb29d5c960 + visibility: operation-bound + - accessProfileIdentifier: registrar + id: registered-business--read--access-profile-registrar-processing + mediaType: application/json + operationIdentifier: registered-business.read + path: generated/artifacts/registered-business--read--access-profile-registrar.processing.json + sha256: sha256:cdfd9e044be2f18addbe824be6f4a0a07379364542577987833759bd48e038ee + visibility: operation-bound + - accessProfileIdentifier: registrar + id: registered-business--read--access-profile-registrar-schema + mediaType: application/schema+json + operationIdentifier: registered-business.read + path: generated/artifacts/registered-business--read--access-profile-registrar.schema.json + sha256: sha256:e183ab8c5f2432d46b6f77f91f57503c7cd60eb904e1913b45d491092d04f6a3 + visibility: operation-bound + - accessProfileIdentifier: registrar + id: registered-business--read--access-profile-registrar-shacl + mediaType: text/turtle + operationIdentifier: registered-business.read + path: generated/artifacts/registered-business--read--access-profile-registrar.shacl.ttl + sha256: sha256:c72599e5a94a0c7a8460b3551ef10506cf9e8fa3bd906c828c4a62092ac54581 + visibility: operation-bound + - accessProfileIdentifier: registrar + id: registered-business--read--access-profile-registrar-vocabulary + mediaType: application/ld+json + operationIdentifier: registered-business.read + path: generated/artifacts/registered-business--read--access-profile-registrar.vocabulary.jsonld + sha256: sha256:1a1d5fec8194398211a8d8ea6618cef48b291b8d42814d94c5cba9af82d84b36 + visibility: operation-bound + - accessProfileIdentifier: null + id: registered-business-classification + mediaType: application/json + operationIdentifier: null + path: generated/artifacts/registered-business.classifications.json + sha256: sha256:b7a423ff392130cfb833df5e2c5829aa3c9bb4c4e6a363deca4461760e75307d + visibility: operator-only + - accessProfileIdentifier: null + 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 + - accessProfileIdentifier: null + 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 + - accessProfileIdentifier: null + 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 + - accessProfileIdentifier: null + 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 + - accessProfileIdentifier: null + id: registered-business-full-schema + mediaType: application/schema+json + operationIdentifier: null + path: generated/artifacts/registered-business.full.schema.json + sha256: sha256:8266c4c05a0c304d255de8c69e78bac01b6d2ad86d540cde95e234ca775878fa + visibility: operator-only + - accessProfileIdentifier: null + id: registered-business-full-shacl + mediaType: text/turtle + operationIdentifier: null + path: generated/artifacts/registered-business.full.shacl.ttl + sha256: sha256:5800a8e5dc107a5d7260e2567e36504afded088c3335af1a53d0969fdb099270 + visibility: operator-only + - accessProfileIdentifier: null + id: registered-business-full-vocabulary + mediaType: application/ld+json + operationIdentifier: null + path: generated/artifacts/registered-business.full.vocabulary.jsonld + sha256: sha256:f57ec119ca7d4dc0534ee8e2c5f8756e336f0f90bd34e22fab18f731baffe181 + visibility: operator-only + - accessProfileIdentifier: null + id: registered-business-processing-full + mediaType: application/json + operationIdentifier: null + path: generated/artifacts/registered-business.processing.full.json + sha256: sha256:9e14c3d53958f18e29ee021c74f6f8ea0ceacb0452d01f5f13f5ea7270006158 + visibility: operator-only + - 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:b31f04d8f49fee827a9eefd3a6bbdf55f1d1233df358974b2a674726bad247ed + 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--read--access-profile-public-premises.classifications.json + sha256: sha256:896e33b6d94776ae3196eeb68c7cae02c592af3aad65326519757c8d79723805 + visibility: public + - accessProfileIdentifier: null + id: registered-premises--read--access-profile-public-premises-context + mediaType: application/ld+json + operationIdentifier: null + path: generated/artifacts/registered-premises--read--access-profile-public-premises.context.jsonld + sha256: sha256:9e5459f441ec270ed225e0f6a8e420a4105fbca0b189e16088aef38a11a5af12 + visibility: public + - accessProfileIdentifier: null + id: registered-premises--read--access-profile-public-premises-geojson-schema + mediaType: application/schema+json + operationIdentifier: null + path: generated/artifacts/registered-premises--read--access-profile-public-premises.geojson.schema.json + sha256: sha256:b0f0dff92c8743dd34d04b6f861a7aa549a73f3d6a3aedbf6b34874e5f82aee2 + visibility: public + - accessProfileIdentifier: null + id: registered-premises--read--access-profile-public-premises-processing + mediaType: application/json + operationIdentifier: null + path: generated/artifacts/registered-premises--read--access-profile-public-premises.processing.json + sha256: sha256:8d8af9e03e0ee99008eea2d865678fb330db321f2f01c416de5f6401de3b38fd + visibility: public + - accessProfileIdentifier: null + id: registered-premises--read--access-profile-public-premises-schema + mediaType: application/schema+json + operationIdentifier: null + path: generated/artifacts/registered-premises--read--access-profile-public-premises.schema.json + sha256: sha256:99bf707298df6740f53a68ab8234865327be245efd472e03a02e07ae3a7ba27d + visibility: public + - accessProfileIdentifier: null + id: registered-premises--read--access-profile-public-premises-shacl + mediaType: text/turtle + operationIdentifier: null + path: generated/artifacts/registered-premises--read--access-profile-public-premises.shacl.ttl + sha256: sha256:d31ea35d3e00da273beb83c53d2848d58114fdddfb946e01489f5334f7f70c99 + visibility: public + - accessProfileIdentifier: null + id: registered-premises--read--access-profile-public-premises-vocabulary + mediaType: application/ld+json + operationIdentifier: null + path: generated/artifacts/registered-premises--read--access-profile-public-premises.vocabulary.jsonld + sha256: sha256:2f2d975c8456d4a7288b5a5f9e44fda8d4b19bf803e05254e446cbbc6e62cb29 + visibility: public + - 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:624bcfa21317b3fc9f6251a216ef3b936c792e4a7deeab70e4acc7487a7d5915 + 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--search-within-bbox--access-profile-public-premises.classifications.json + sha256: sha256:ec2622189784ead9216c3e4ca5117473eb7bc2111a7c5aac25a092fc3459b856 + visibility: public + - accessProfileIdentifier: null + id: registered-premises--search-within-bbox--access-profile-public-premises-context + mediaType: application/ld+json + operationIdentifier: null + path: generated/artifacts/registered-premises--search-within-bbox--access-profile-public-premises.context.jsonld + sha256: sha256:9e5459f441ec270ed225e0f6a8e420a4105fbca0b189e16088aef38a11a5af12 + visibility: public + - 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--search-within-bbox--access-profile-public-premises.geojson.schema.json + sha256: sha256:2f78765736ff332456e84250cc466e1f0b6c2b42ff9ba968e7b80b5e4aec80ea + visibility: public + - accessProfileIdentifier: null + id: registered-premises--search-within-bbox--access-profile-public-premises-processing + mediaType: application/json + operationIdentifier: null + path: generated/artifacts/registered-premises--search-within-bbox--access-profile-public-premises.processing.json + sha256: sha256:1346f2361d748c1af103a515ee09642cc588a3919d48351a66db4a42d9e093ec + visibility: public + - accessProfileIdentifier: null + id: registered-premises--search-within-bbox--access-profile-public-premises-schema + mediaType: application/schema+json + operationIdentifier: null + path: generated/artifacts/registered-premises--search-within-bbox--access-profile-public-premises.schema.json + sha256: sha256:1ad230a76cef7a32358732aa8a960263078c7b24af2bdbff6b150f77f45db8a1 + visibility: public + - accessProfileIdentifier: null + id: registered-premises--search-within-bbox--access-profile-public-premises-shacl + mediaType: text/turtle + operationIdentifier: null + path: generated/artifacts/registered-premises--search-within-bbox--access-profile-public-premises.shacl.ttl + sha256: sha256:d31ea35d3e00da273beb83c53d2848d58114fdddfb946e01489f5334f7f70c99 + visibility: public + - accessProfileIdentifier: null + id: registered-premises--search-within-bbox--access-profile-public-premises-vocabulary + mediaType: application/ld+json + operationIdentifier: null + path: generated/artifacts/registered-premises--search-within-bbox--access-profile-public-premises.vocabulary.jsonld + sha256: sha256:2f2d975c8456d4a7288b5a5f9e44fda8d4b19bf803e05254e446cbbc6e62cb29 + visibility: public + - 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:134ff087810e520d23ea53a90c188e4c76bda92279e072a5a45abc19172afb03 + 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 + sha256: sha256:f3d43fd7530a656713444a232d0ac5517ace5b04a1235caf3a1fc5f5ddf95e49 + visibility: operator-only + - accessProfileIdentifier: null + id: registered-premises-codelist-0 + mediaType: application/schema+json + operationIdentifier: null + path: generated/artifacts/registered-premises.codelist-0.schema.json + sha256: sha256:e42dfbcab45a66032d126e0f203523ae44a6bc034278f2ce222f96f1ff0a78f0 + visibility: operator-only + - accessProfileIdentifier: null + id: registered-premises-full-schema + mediaType: application/schema+json + operationIdentifier: null + path: generated/artifacts/registered-premises.full.schema.json + sha256: sha256:7235a46db233c30dfdb4bb22834a111e106bd7768db273976639775664728b18 + visibility: operator-only + - accessProfileIdentifier: null + id: registered-premises-full-shacl + mediaType: text/turtle + operationIdentifier: null + path: generated/artifacts/registered-premises.full.shacl.ttl + sha256: sha256:b138dab8da2dcda7fb717bbcab3ca20dd45dfa7dd24e39eb13af05b2472bd5fe + visibility: operator-only + - accessProfileIdentifier: null + id: registered-premises-full-vocabulary + mediaType: application/ld+json + operationIdentifier: null + path: generated/artifacts/registered-premises.full.vocabulary.jsonld + sha256: sha256:3af77a0e9a5c087638b560ff6da1c886d0e6ab11f5961e6882d4cb69b60fb994 + visibility: operator-only + - accessProfileIdentifier: null + id: registered-premises-processing-full + mediaType: application/json + operationIdentifier: null + path: generated/artifacts/registered-premises.processing.full.json + sha256: sha256:68d4d19f9cd242b1344adf7862fff9579b0a254c78146c48efc5961f5a452385 + visibility: operator-only + - accessProfileIdentifier: null + id: openapi-full + mediaType: application/yaml + operationIdentifier: null + path: generated/openapi.full.yaml + sha256: sha256:9dd9d6da2119f488237d5a43af44858269f301d2d220a6fd668ec13b05b02d7e + visibility: operator-only + - accessProfileIdentifier: null + id: openapi-public + mediaType: application/json + operationIdentifier: null + path: generated/openapi.public.json + sha256: sha256:6ac224660b2bb76d15b89ae8fac9abd5bdeac96e893951f4e2d5e2329e85bbda + 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/classification-review-rationale.md + sha256: sha256:f7990fb19be17029896efa2b036b4bf8a01eaea42ac49b159604d1ec0bf7bb81 + size: 227 + visibility: operator-only + - generated: false + mediaType: application/yaml + path: governed/governance/classification-review.yaml + sha256: sha256:24f31562b106281ec578657f5e0c70df2540332f26485fd246b7b9abaeba12cd + size: 423 + 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:6a46a9be0a3d5b4a5650934c7e8ef73ad1803cb479981f7d235a1c17a335af52 + size: 668 + visibility: operator-only + - generated: false + mediaType: application/yaml + path: registry.yaml + sha256: sha256:70e3d53e8f6ce3522669bff7ffe480754574ed529fb068b35008e9d6da90f606 + size: 10965 + visibility: operator-only + civil-event: + packageRevision: sha256:ab90a441c87cac2aa8206bb460b30a7f0466c0dcc2b6f0b719fc43c82f3c2ee1 + contractRevision: sha256:011d151e19402f7c4b4bd02c10be4afa93567c5e0cc1e6db28a03a7d1560e8e0 + sourceSchemaFingerprints: + events: sha256:7f770d64cb19ec54caca2aa56378b13a43cd5edc206ff44b5fecc99ee9e63759 + artifacts: + - accessProfileIdentifier: null + id: audit-event-schema + mediaType: application/schema+json + operationIdentifier: null + path: generated/artifacts/audit-event.schema.json + sha256: sha256:2600120dbc7fbbb0f8d4feaa7cb811055b6f2590ad83c4472d5af982ae004a45 + visibility: operator-only + - accessProfileIdentifier: null + id: capability-inventory-full + mediaType: application/json + operationIdentifier: null + path: generated/artifacts/capabilities.full.json + sha256: sha256:f6e37d3443f1dbf9bcdbe066303840445ed8e34ae74cfa13b31b4cb70cd20d69 + visibility: operator-only + - accessProfileIdentifier: null + id: capability-inventory + mediaType: application/json + operationIdentifier: null + path: generated/artifacts/capabilities.json + 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:81f7b8dc8bed62ce65b6e540618f2e334c6b23d0dcbe42f3fb289f5f2c93747f + visibility: operation-bound + - 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--access-profile-registrar-verification.classifications.json + sha256: sha256:308553521c3108baf781299bd10c25eec92f71f8eb0dcee1b5211dd953968d2a + visibility: operator-only + - 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--access-profile-registrar-verification.context.jsonld + sha256: sha256:cecc395f6eab42ed11603ded1b76d25980ede8fbe9e2b9adb02a71b8c3a4e423 + visibility: operation-bound + - 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--access-profile-registrar-verification.processing.json + sha256: sha256:21021162d3ede3099de37e1a142d35751fef060505ca482b5eae4657b2970e4c + visibility: operation-bound + - 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--access-profile-registrar-verification.schema.json + sha256: sha256:02d8aaf26d8f5f26522afdea355080238b3f0a4e5492e84937227212f1914d71 + visibility: operation-bound + - 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--access-profile-registrar-verification.shacl.ttl + sha256: sha256:bd2e2326bc3e25c614dc239aa5bb56ee371f57b6155d8ced40ea2eccfcdaaf7a + visibility: operation-bound + - 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--access-profile-registrar-verification.vocabulary.jsonld + sha256: sha256:fdcf02c1ff87421d65b707e8dd0de30432d2650b9c53914e55002218d4da1cb1 + visibility: operation-bound + - 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--access-profile-supervisory.capability.json + sha256: sha256:285492f6ee4baa35ef9540665188f55ceb07dc0148efe84d9d22a7d842ccfbea + visibility: operation-bound + - 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--access-profile-supervisory.classifications.json + sha256: sha256:89cfc3c62a0c1181618fb840c104ac864d006722161a7ff4b8a9af72ba3d3565 + visibility: operator-only + - 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--access-profile-supervisory.context.jsonld + sha256: sha256:e4408efdb0ddfed828dc8148f36f86c045ec0f558766ec5275908435dd92c689 + visibility: operation-bound + - 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--access-profile-supervisory.processing.json + sha256: sha256:b0f5dcd7b36e3a39c585a7e31ad16327332abb7c89ff4f982248cbfbeb490633 + visibility: operation-bound + - 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--access-profile-supervisory.schema.json + sha256: sha256:aae0ca59f36bd97aadf536c755d20175a41578b8646482a55f2aa43b1b67acc2 + visibility: operation-bound + - 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--access-profile-supervisory.shacl.ttl + sha256: sha256:193cdd4cc378c7252c0c734354ef5e9f8f5eea4eafc5a8dcf9729d0a48e69a7a + visibility: operation-bound + - 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--access-profile-supervisory.vocabulary.jsonld + sha256: sha256:f69da73736b4c0847cb66bb1524fb8f6182b7ff71c81eecc73f3588778d172d7 + visibility: operation-bound + - accessProfileIdentifier: registrar + id: civil-event--read--access-profile-registrar-capability + mediaType: application/json + operationIdentifier: civil-event.read + path: generated/artifacts/civil-event--read--access-profile-registrar.capability.json + sha256: sha256:03042beaa51ae53a06a23da5e84c5a2a164203c3085cb56daa6066db8f0eb69d + visibility: operation-bound + - accessProfileIdentifier: null + id: civil-event--read--access-profile-registrar-classifications + mediaType: application/json + operationIdentifier: null + path: generated/artifacts/civil-event--read--access-profile-registrar.classifications.json + sha256: sha256:8dd41e9146bcb6972dd66ba897c35df25472584ac278dadea283a3e4006c055a + visibility: operator-only + - accessProfileIdentifier: registrar + id: civil-event--read--access-profile-registrar-context + mediaType: application/ld+json + operationIdentifier: civil-event.read + path: generated/artifacts/civil-event--read--access-profile-registrar.context.jsonld + sha256: sha256:44bc76f5795bbf1fc53b33373b901a5ce1bf612f459a07715db6dd71ae1f2d5d + visibility: operation-bound + - accessProfileIdentifier: registrar + id: civil-event--read--access-profile-registrar-processing + mediaType: application/json + operationIdentifier: civil-event.read + path: generated/artifacts/civil-event--read--access-profile-registrar.processing.json + sha256: sha256:fdb6099a53ca0f2127a5d45e8084947829de9d95de7ab49a8e32ff4abf6bfe9c + visibility: operation-bound + - accessProfileIdentifier: registrar + id: civil-event--read--access-profile-registrar-schema + mediaType: application/schema+json + operationIdentifier: civil-event.read + path: generated/artifacts/civil-event--read--access-profile-registrar.schema.json + sha256: sha256:b725779e58e861f93a09e8933b89a1608f82cc1de08809fbd28f0fdd34597009 + visibility: operation-bound + - accessProfileIdentifier: registrar + id: civil-event--read--access-profile-registrar-shacl + mediaType: text/turtle + operationIdentifier: civil-event.read + path: generated/artifacts/civil-event--read--access-profile-registrar.shacl.ttl + sha256: sha256:33cb54f0a1a3a35a132e50a78a40b2c2f7dd5abaf0a7fd768e32fb4a1bb2f390 + visibility: operation-bound + - accessProfileIdentifier: registrar + id: civil-event--read--access-profile-registrar-vocabulary + mediaType: application/ld+json + operationIdentifier: civil-event.read + path: generated/artifacts/civil-event--read--access-profile-registrar.vocabulary.jsonld + sha256: sha256:6a8225b7efed28ae336c11cbeec58bc94eaf89dcd18d2a097bc76c470f33ab85 + visibility: operation-bound + - accessProfileIdentifier: null + id: civil-event-classification + mediaType: application/json + operationIdentifier: null + path: generated/artifacts/civil-event.classifications.json + sha256: sha256:c8c9dbacda4149f0e70cf6bd0cbc15b1876c0faf2ccca4bac0eff8d189e4c2a2 + visibility: operator-only + - accessProfileIdentifier: null + 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 + - accessProfileIdentifier: null + 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 + - accessProfileIdentifier: null + 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 + - accessProfileIdentifier: null + 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 + - accessProfileIdentifier: null + id: civil-event-full-schema + mediaType: application/schema+json + operationIdentifier: null + path: generated/artifacts/civil-event.full.schema.json + sha256: sha256:8719ed7bf8ccb512b1da1a2ed33e70308a4f2e75d919d3b62d071ba6e76a8bbb + visibility: operator-only + - accessProfileIdentifier: null + id: civil-event-full-shacl + mediaType: text/turtle + operationIdentifier: null + path: generated/artifacts/civil-event.full.shacl.ttl + sha256: sha256:fbb6bb7991d85d5de37e5d4115318c43dc108a1a61f3449496a635221a435642 + visibility: operator-only + - accessProfileIdentifier: null + id: civil-event-full-vocabulary + mediaType: application/ld+json + operationIdentifier: null + path: generated/artifacts/civil-event.full.vocabulary.jsonld + sha256: sha256:437d021d8cd85c4e7847dc9df983c7375a8332efb0b5ac77ce7a5fda4fda3b58 + visibility: operator-only + - accessProfileIdentifier: null + id: civil-event-processing-full + mediaType: application/json + operationIdentifier: null + path: generated/artifacts/civil-event.processing.full.json + sha256: sha256:762086646e734b6a8248a6bb62675490edc9a303559dca7e08719925656fec40 + visibility: operator-only + - accessProfileIdentifier: null + id: openapi-full + mediaType: application/yaml + operationIdentifier: null + path: generated/openapi.full.yaml + sha256: sha256:ed915507e41e83ffcf72f7cbf22f90073c4fd507e3834b98f0e854668d808a5e + visibility: operator-only + - accessProfileIdentifier: null + 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-selector-types.yaml + sha256: sha256:b50078dbd85a1aef52a98394578e8d4b8e3132833f9e5d785b0f60854d0d53c6 + size: 92 + visibility: operator-only + - 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/classification-review-rationale.md + sha256: sha256:58606b2d7a0c69145ca9f1e951f2851701b75b0753afa86801f89acf51a20685 + size: 258 + visibility: operator-only + - generated: false + mediaType: application/yaml + path: governed/governance/classification-review.yaml + sha256: sha256:11c7200188e146f82dc2e31986b0edcd476c619eaab6616a35114770cfcbee20 + size: 423 + 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:8954bcc3af8c5178f8539d3f43b299a89c421d8a630d901a8d11e0882ad0328a + size: 8686 + 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..da02f53c9 --- /dev/null +++ b/products/relay-v2/contracts/package-layout.yaml @@ -0,0 +1,38 @@ +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 + - 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. +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..4ce615888 --- /dev/null +++ b/products/relay-v2/contracts/security-invariant-matrix.yaml @@ -0,0 +1,287 @@ +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. + 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. + 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. + 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. + 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: 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. + enforcementPoint: Hardcoded route construction from compiled operation kinds and exact operation scopes. + 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} + - 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. + 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: + - {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-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-access-profile-tests + tests: + - {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_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. + 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/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/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. + 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. + 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. + 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, or policy outcomes. + enforcementPoint: Bounded body parsing, bound selectors, at-most-two-row execution, and one unresolved problem contract. + 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 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/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. + 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. + 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. + 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 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_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_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 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_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: 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: 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. + 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: 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} + - {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. + 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} + - {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. + 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} + - {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. + 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} + - {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. + 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-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..8d92abd07 --- /dev/null +++ b/products/relay-v2/scripts/check-contracts.sh @@ -0,0 +1,14 @@ +#!/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" \ + "$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/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..c38a77861 --- /dev/null +++ b/products/relay-v2/scripts/check-source-neutrality.sh @@ -0,0 +1,33 @@ +#!/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" \ + "$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 + +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..ee56e077a --- /dev/null +++ b/products/relay-v2/scripts/test_adopter_workflow.py @@ -0,0 +1,538 @@ +#!/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 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 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 access profiles") + identifiers: set[str] = set() + for profile in profiles: + if not isinstance(profile, dict) or not isinstance( + profile.get("accessProfileIdentifier"), str + ): + raise GateFailure(f"{label} has a malformed access profile") + identifier = profile["accessProfileIdentifier"] + if not identifier or identifier in identifiers: + raise GateFailure(f"{label} has duplicate or empty access-profile identifiers") + identifiers.add(identifier) + return identifiers + + +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") + matches = [ + parameter + for parameter in parameters + if isinstance(parameter, dict) + and parameter.get("name") == "accessProfile" + and parameter.get("in") == "query" + ] + if len(matches) != 1: + 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 accessProfile 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 = 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 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["accessProfileIdentifier"]: profile + for profile in full["x-registry-access-profiles"] + } + protected_ids = { + entry.get("accessProfileIdentifier") + for entry in full.get("x-registry-required-scopes", []) + if isinstance(entry, dict) + and isinstance(entry.get("accessProfileIdentifier"), str) + } + for profile in public["x-registry-access-profiles"]: + identifier = profile["accessProfileIdentifier"] + if identifier in protected_ids: + raise GateFailure("public OpenAPI exposes a protected access profile") + if profile != full_profiles[identifier]: + raise GateFailure("public OpenAPI access profile 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(): + full_operation = full_operations.get(key) + if full_operation is None: + raise GateFailure("public OpenAPI path is absent from full OpenAPI") + 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") + + 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/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(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"): + 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, artifacts) + 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 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" + 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", "--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") != []: + 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") + 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}-") 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", + "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_adopter_workflow_openapi.py b/products/relay-v2/scripts/test_adopter_workflow_openapi.py new file mode 100644 index 000000000..524691407 --- /dev/null +++ b/products/relay-v2/scripts/test_adopter_workflow_openapi.py @@ -0,0 +1,76 @@ +#!/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_access_profile_in_public_output(self) -> None: + public_profile = { + "accessProfileIdentifier": "public-register", + "isDefault": 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, + "accessProfileIdentifier": "registrar", + "isDefault": 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-access-profiles": [public_profile, protected_profile], + "x-registry-required-scopes": [ + { + "accessProfileIdentifier": "registrar", + "scope": "registry:business:read-registrar", + } + ], + } + public = { + "operationId": "business.read", + "security": [], + "parameters": [ + { + "name": "accessProfile", + "in": "query", + "schema": {"enum": ["public-register", "registrar"]}, + } + ], + "x-registry-access-profiles": [public_profile, copy.deepcopy(protected_profile)], + } + with self.assertRaisesRegex(WORKFLOW.GateFailure, "protected access profile"): + 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 new file mode 100644 index 000000000..21804f973 --- /dev/null +++ b/products/relay-v2/scripts/test_validate_product.py @@ -0,0 +1,351 @@ +#!/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_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( + "defaultAccessProfile" + ) + return value + + errors: list[str] = [] + with mock.patch.object(VALIDATOR, "load_yaml", side_effect=load_without_social_lookup_default): + VALIDATOR.validate_acceptance_access_profile_contracts(errors) + self.assertTrue( + any("every declared operation needs one declared default access profile" 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_access_profile_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_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 + + 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_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_access_profiles_share_one_outcome(self) -> None: + original = VALIDATOR.load_yaml + + 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-access-profile" + ) + 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_access_profile + ): + VALIDATOR.validate_catalogs(errors) + self.assertTrue( + any("must conceal access-profile 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( + { + "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]) + + 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 new file mode 100644 index 000000000..697d1a86a --- /dev/null +++ b/products/relay-v2/scripts/validate_product.py @@ -0,0 +1,697 @@ +#!/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", +} +TRANSFORM_FAILURE_SCENARIOS = { + "social-invalid-transform", + "civil-invalid-transform", +} +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", + "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-classification-review-binding", + "sec-finite-access-profile-authorization", + "sec-public-access-profile-processing-floor", + "sec-closed-mask-and-date-transforms", + "sec-access-profile-state-and-metadata-binding", + "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-spatial-disclosure-confinement", + "sec-spatial-query-confinement", + "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( + 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, 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 ( + "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) + authorizations = mapping( + journey.get("authorizations"), f"{project_name} journey authorizations", errors + ) + identifiers: dict[str, tuple[Any, Any]] = {} + 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 + 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( + f"{project_name}: {identifier} references unknown authorization fixture {authorization}" + ) + result[project_name] = identifiers + 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_access_profile_contracts(errors: list[str]) -> None: + expected_methods = { + "social-assistance": "generated", + "business-registry": "imported", + "civil-event": "manual", + } + expected_access_profiles = { + "social-assistance": {"limited", "caseworker"}, + "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) + 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, + ) + 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 access profile 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 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 + ) + 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" + ) + 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_access_profile_contracts(errors) + 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", + "access-profile-schema", + "access-profile-shacl", + "full-record-schema", + "full-record-shacl", + "semantic-model", + "jsonld-context", + "shacl-shape", + "codelists", + "capability-inventory", + "audit-event-schema", + "identification-report", + "classification-inventory", + "operation-explanation", + "contextual-review-findings", + "classification-review", + }: + if required not in artifact_ids: + errors.append(f"artifact inventory: missing {required}") + + steps = journey_steps(errors) + 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 access-profile existence as 404 resource.not_found" + ) + 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.update({"invalidSourceRowClass", "expectedStatus", "expectedCode"}) + 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, {}): + 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) + 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] != 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") + 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)) + ) + 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"), + "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) + ): + invariant = mapping(raw, f"security invariant[{index}]", errors) + require_exact_keys( + invariant, + { + "id", + "threat", + "enforcementPoint", + "expected", + "evidence", + "negativeTest", + "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) + 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 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"), + "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/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-gates-inventory.py b/release/scripts/check-gates-inventory.py index 4cc15e482..3417bf61a 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"), @@ -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/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_gates_inventory.py b/release/scripts/test_check_gates_inventory.py index 3f5010428..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", @@ -643,7 +665,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"]), )